completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

DOM & rendering performance

Learn the browser rendering pipeline, prevent layout thrashing, batch DOM reads and writes, and virtualize long lists smoothly.

By the end, you can
  • 01
    Name the five steps of a frameExplain JavaScript, Style, Layout, Paint, and Composite, and how they share the short time one frame has.
  • 02
    Stop forced layoutsSpot a size read that comes right after a write, count forced layouts with a small model, and fix it by reading first.
  • 03
    Put less on the pageUse fragments, contain, observers, and virtual lists, and know what each one costs.

Keep the work for each frame small

When something on a page changes, the browser draws the page again. Each new drawing of the screen is called a frame. Rendering performance is about keeping the work for each frame small. Then the page reacts fast and moves smoothly.

This lesson uses ideas from earlier lessons:

Plain definition

Rendering performance means changing, measuring, drawing, and moving things on the page without making the browser do extra work inside one frame.

A screen redraws many times each second. This speed is called the refresh rate. It is measured in hertz (Hz): 60Hz means 60 times a second. So at 60Hz, the browser has about 16.7ms to make one frame. (A millisecond, ms, is a thousandth of a second.) At 120Hz it has about 8.3ms.

That time is shared. Your JavaScript uses it. The browser's own drawing steps use it. Handling clicks and key presses uses it too. If the work takes longer, the browser misses a frame and the page stutters. People call this stutter jank.

Work out the time between framesPop out in the code editor (opens in a new tab)JavaScript
const refreshRates = [60, 120];
for (const refreshRate of refreshRates) {
  console.log((1000 / refreshRate).toFixed(1));
}

Line 1 lists two refresh rates, 60 and 120. Line 3 divides 1,000 milliseconds (one second) by each rate. Then toFixed(1) rounds the answer to one decimal place. So the code prints 16.7 and then 8.3. This is the time between two frames. Your JavaScript gets only part of it.

Real-life analogyA painter who keeps measuring the room

A painter is painting a room. After every single brush stroke, she asks a helper to measure the whole room again. Each measurement is correct, but most of them are wasted. It is faster to measure once, paint many strokes, and measure again only when needed.

In real life: The painter paints one stroke
In JavaScript: JavaScript writes a style
In real life: She asks for the room to be measured right away
In JavaScript: Code reads offsetHeight or getBoundingClientRect()
In real life: A helper measures the whole room again
In JavaScript: The browser runs layout right now
In real life: This happens after every stroke
In JavaScript: A write-then-read loop causes layout thrashing
In real life: Measure once, then paint many strokes
In JavaScript: Do all reads first, then all writes

Other lessons cover nearby topics:

This lesson is about the drawing work that each handler causes.

The five steps of a frame

PIPELINE

To draw a frame, the browser can run up to five steps, always in the same order. This list of steps is called the rendering pipeline. The browser skips a step when nothing has changed for it.

The five steps, in orderPop out in the code editor (opens in a new tab)JavaScript
const pipeline = [  "JavaScript",  "Style",  "Layout",  "Paint",  "Composite",]; console.log(pipeline.join(" -> "));

The code prints the five steps joined by arrows. Here is what each step does:

  1. JavaScript: your code runs. It may change elements, styles, classes, or the scroll position.
  2. Style: the browser works out which CSS rules apply to each element.
  3. Layout (also called reflow): the browser works out how big each box is and where it sits.
  4. Paint: the browser draws the pixels: backgrounds, borders, shadows, text, and images.
  5. Composite: the browser puts the painted parts together on the screen. A painted part that can move on its own is called a layer. Moving or fading a whole layer can happen in this step alone. That is often how transform and opacity work.
One frame in five steps, left to right: JavaScript runs your code, Style finds the CSS, Layout works out sizes and places, Paint draws pixels, Composite moves layers. At 60Hz all of this shares about 16.7 milliseconds.one frame: about 16.7 ms at 60HzJavaScriptStyleLayoutPaintCompositeyour codefinds CSSsizes, placesdrawsmoves layers
The browser runs these steps from left to right to draw one frame. At 60Hz, all five share about 16.7 ms.

CSS is not slow, and the DOM is not slow. The cost comes from waking up more steps than a change needs. It gets much worse when your code does that many times in a row.

Different CSS properties wake up different steps. The next example lists a few common ones.

Which step each property usually wakes upPop out in the code editor (opens in a new tab)JavaScript
const propertyCosts = {  width: "layout",  paddingTop: "layout",  boxShadow: "paint",  backgroundColor: "paint",  transform: "composite",  opacity: "composite",}; console.log(propertyCosts.transform);console.log(propertyCosts.width);
  • Lines 2 and 3 change the size of a box, so they need layout. Paint and composite follow.
  • Lines 4 and 5 change only how the box looks, so they start at paint.
  • Lines 6 and 7 can often be done in the composite step alone.
  • The last two lines print composite and layout.
Three changes and the steps they wake up. width: Layout, Paint, and Composite. boxShadow: Paint and Composite. transform: often only Composite.widthLayoutPaintComposite3 stepsboxShadowPaintComposite2 stepstransformCompositeoften 1 step
A width change wakes up all three steps. A transform change often wakes up only Composite.

Here is a small message box, called a toast, that slides up and fades in. It changes only transform and opacity.

Slide in a toast with transform and opacityCSS
.toast {
  opacity: 0;
  transform: translateY(8px);
  transition: transform 180ms ease, opacity 180ms ease;
}

.toast.is-visible {
  opacity: 1;
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  .toast { transition: none; }
}
  • The first rule (lines 1 to 5) starts the toast 8px lower and fully see-through (opacity: 0). Line 4 says both changes should animate over 180ms.
  • The second rule (lines 7 to 10) applies when the toast gets the is-visible class. It moves the toast to its final place and makes it fully visible.
  • The last rule (lines 12 to 14) turns the animation off when the user has asked their device for less motion.

Browsers can often do these two changes in the composite step. It still depends on the page and the device.

will-change is a hint to the browser. It says: "this element is about to change this property, so get ready."

Turn a will-change hint on, then offPop out in the code editor (opens in a new tab)JavaScript
const card = document.createElement("article");
card.style.willChange = "transform";
console.log(card.style.willChange);
card.style.willChange = "auto";
console.log(card.style.willChange);

Line 2 tells the browser the card may soon move with transform. Line 4 sets the hint back to auto, which means "no hint". So the code prints transform, then auto.

A hint does not promise a faster animation. Add one only after you have measured a real problem.

Which step does this property usually wake up?
  • `width`
  • `padding`
  • `top` on a positioned element
  • `background-color`
  • `box-shadow`
  • `color`
  • `transform: translateX(...)`
  • `opacity`
  • `will-change: transform`
Try it yourself
0 of 9 correct

Put each property under the first step it usually needs. Each explanation names the exceptions.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

The table below compares the four steps after JavaScript. Read one row at a time: the step, what it works out, what wakes it up, and how to keep it small.

The four browser steps after JavaScript, compared
StepWhat does it work out?What wakes it up?How do you keep it small?
StyleWhich CSS rules apply to each element, and their final values.Class changes, media queries, and getComputedStyle() reads.Don't read styles right after writes inside a loop.
LayoutHow big each box is and where it sits.width, padding, text changes, and offsetHeight reads.Do all size reads before any write that changes sizes.
PaintWhich pixels need drawing.background-color, box-shadow, and color.Keep the painted area small. Use contain to keep each card separate.
CompositeHow the painted layers are moved and faded on the screen.transform, opacity, and elements that have their own layer.Use it for movement. Each layer uses memory, so use will-change only for a short time.
Use will-change on a few elements, for a short time

will-change: transform can make the browser prepare a layer before the movement starts. Each layer uses memory. Remove the hint when the movement is over. Never add it to hundreds of elements "just in case".

Layout thrashing: reading right after writing

REAL DEMO

Sometimes your code needs the size or position of an element. Asking for it is called a layout read. Common layout reads are:

  • offsetHeight
  • getBoundingClientRect()
  • scrollTop
  • getComputedStyle()

A layout read is fine on its own. The problem is a read that comes right after a write: a change that can move or resize things, like a new width. The browser can't give you an old answer. So it pauses your JavaScript and runs layout right away. This is called a forced layout (or forced reflow).

Now picture a loop that does "write, read, write, read" for many elements. The browser runs layout once for every element. This is called layout thrashing.

Real timings change from one computer to the next. So the next example uses a small pretend model instead of the real browser. It follows the same rule, and it always gives the same answer.

A pretend layout that counts forced layoutsPop out in the code editor (opens in a new tab)JavaScript
function createInstrumentedLayout(count) {  let dirty = false;  let forcedLayouts = 0;  const boxes = Array.from({ length: count }, (_, index) => {    const box = {      height: 24 + (index % 5),      readHeight() {        if (dirty) {          forcedLayouts += 1;          dirty = false;        }        return box.height;      },      writeHeight(next) {        box.height = next;        dirty = true;      },    };    return box;  });  return { boxes, get forcedLayouts() { return forcedLayouts; } };} function runLayoutFlushModel(count, mode) {  const model = createInstrumentedLayout(count);  let totalHeight = 0;   if (mode === "thrash") {    for (const box of model.boxes) {      box.writeHeight(box.height + 1);      totalHeight += box.readHeight();    }  } else {    const heights = model.boxes.map((box) => box.readHeight());    heights.forEach((height, index) => {      model.boxes[index].writeHeight(height + 1);      totalHeight += height + 1;    });  }   return { forcedLayouts: model.forcedLayouts, totalHeight };} console.log(runLayoutFlushModel(5, "thrash").forcedLayouts);console.log(runLayoutFlushModel(5, "batched").forcedLayouts);

How the model works:

  • Each box has a height, a readHeight() method, and a writeHeight() method.
  • A write sets dirty to true (line 16). Here dirty means "the layout is out of date".
  • A read checks dirty (line 8). If it is true, the read adds 1 to forcedLayouts and sets dirty back to false (lines 9 and 10).
  • In "thrash" mode, each box is written and then read straight away (lines 30 and 31).
  • In "batched" mode, all heights are read first (line 34). Then all boxes are written (lines 35 to 37).

With 5 boxes, the code prints 5 for thrash mode and 0 for batched mode.

Left, thrash mode: write, then read, for each of 5 boxes, so 5 forced layouts. Right, batched mode: 5 reads first, then 5 writes, so 0 forced layouts."thrash"writereadfor each of 5 boxes5 forced layouts"batched"5 reads5 writesall reads first0 forced layouts
Thrash mode reads right after each write, so every read makes the browser do layout now. Batched mode reads everything first, so no read has to wait.
Thrash mode vs batched mode
Thrash mode on real boxes: write, then readPop out in the code editor (opens in a new tab)JavaScript
const boxes = [...document.querySelectorAll(".rp-run-box")]; for (const [index, box] of boxes.entries()) {  box.style.inlineSize = 120 + index + "px";  box.getBoundingClientRect();} console.log("touched " + boxes.length + " boxes");
Modenot run
Time taken--
Forced layouts (model)--
Total height--
Real boxesrun 0
Box 1
Box 2
Box 3
Box 4
Box 5
Box 6
Box 7
Box 8
Box 9
Box 10
Box 11
Box 12
Box 13
Box 14
Box 15
Box 16
Box 17
Box 18
Box 19
Box 20
Box 21
Box 22
Box 23
Box 24
Box 25
Box 26
Box 27
Box 28
Box 29
Box 30
Box 31
Box 32
Box 33
Box 34
Box 35
Box 36
Box 37
Box 38
Box 39
Box 40
Box 41
Box 42
Box 43
Box 44
Box 45
Box 46
Box 47
Box 48
Box 49
Box 50
Box 51
Box 52
Box 53
Box 54
Box 55
Box 56
Box 57
Box 58
Box 59
Box 60
Box 61
Box 62
Box 63
Box 64
Box 65
Box 66
Box 67
Box 68
Box 69
Box 70
Box 71
Box 72
Try it yourself

Run both modes. The times are real and change on every run. The forced-layout count comes from the pretend model, so it always stays the same.

The times come from real boxes on the page. The forced-layout count comes from the pretend model in the lesson. Reset clears the boxes' inline styles.

The demo above uses real boxes on the page and real time, so your timings will differ from anyone else's. The forced-layout count comes from the pretend model, so it never changes. Thrash mode forces one layout per box. Batched mode forces none, because every read happens before any write.

Do all reads first, then all writes

STEP THROUGH

The fix is simple. Do the work in three steps:

  1. Read all the sizes and positions you need.
  2. Decide what to change.
  3. Write all the changes.

When the writes change what the user sees, put them inside requestAnimationFrame. This browser function takes a callback and runs it once, just before the next frame is drawn.

Some libraries, such as FastDOM, use two words for this: measure for reads and mutate for writes. The step-through below is a tiny scheduler: a helper that decides when each task runs. It keeps reads in one list and writes in another.

Step through a read and write scheduler
Step 0 of 11Ready
Your turn: follow the blue line

Step through a tiny scheduler, like FastDOM, that keeps reads and writes in two lists until the next frame.

Running in
  1. script
Next: line 2
function createFrameScheduler(requestFrame) {  const writes = [];  let scheduled = false;   function schedule() {    if (scheduled) return;    scheduled = true;    requestFrame(flush);  }   function measure(task) {    reads.push(task);    schedule();  }   function mutate(task) {    writes.push(task);    schedule();  }   function flush() {    scheduled = false;    const readBatch = reads.splice(0);    const writeBatch = writes.splice(0);    readBatch.forEach((task) => task());    writeBatch.forEach((task) => task());  }   return { measure, mutate };} const frames = [];const log = [];const scheduler = createFrameScheduler((flush) => frames.push(flush));scheduler.mutate(() => log.push("write: transform"));scheduler.measure(() => log.push("read: rect"));frames.shift()();console.log(log.join(" -> "));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

The code asks for the write first (line 36) and the read second (line 37). When the frame runs, the scheduler still runs the read first, so the code prints read: rect -> write: transform.

The next example updates the screen only once per frame, even when new values arrive faster. It uses its own small requestAnimationFrame on line 4, so you can see what happens. That version saves each callback in the frames list instead of waiting for a real frame.

Update the screen once per frame, with the newest valuePop out in the code editor (opens in a new tab)JavaScript
const frames = [];
let pending = false;
let latestX = 0;
function requestAnimationFrame(callback) { frames.push(callback); }

function moveTo(x) {
  latestX = x;
  if (pending) return;
  pending = true;
  requestAnimationFrame(() => {
    pending = false;
    console.log("translateX(" + latestX + "px)");
  });
}

moveTo(12);
moveTo(28);
console.log(frames.length);
frames.shift()();
  • Lines 16 and 17 call moveTo twice, first with 12 and then with 28, before any frame runs.
  • Each call saves the newest value in latestX (line 7).
  • Only the first call asks for a frame. On the second call, pending is already true, so line 8 stops it.
  • Line 18 prints 1, because only one callback is waiting.
  • Line 19 runs that callback. It prints translateX(28px), the newest value.

The real requestAnimationFrame runs its callback once, just before the next paint. It still runs on the main thread, the same place as the rest of your page code.

When you add many elements, you can build them away from the page first. A DocumentFragment is a light container that is not on the page. You put elements inside it, then add them all to the page in one step.

Add many items at once with a fragmentPop out in the code editor (opens in a new tab)JavaScript
const list = document.createElement("ul");const fragment = document.createDocumentFragment(); for (const label of ["Read", "Measure", "Mutate"]) {  const item = document.createElement("li");  item.textContent = label;  fragment.append(item);} list.replaceChildren(fragment);console.log(list.children.length);console.log(fragment.childNodes.length);
  • Line 2 makes the fragment.
  • Lines 4 to 7 make three li items and put them in the fragment.
  • Line 10 moves all three items into the list with replaceChildren.
  • Line 11 prints 3: the list now has three children.
  • Line 12 prints 0: the items moved out, so the fragment is now empty.

This is one change to the page, instead of three separate ones.

Use a class for a state and a CSS variable for a numberPop out in the code editor (opens in a new tab)JavaScript
const card = document.createElement("article");card.className = "card";card.classList.toggle("is-open", true);card.style.setProperty("--progress", "0.64"); console.log(card.className);console.log(card.style.getPropertyValue("--progress"));

Line 2 gives the card the class card. Line 3 adds the class is-open. Line 4 sets the CSS variable --progress to 0.64. So the code prints card is-open and 0.64.

The Styles & classes lesson explained when to use which. A class picks a look you designed in CSS. An inline style or CSS variable carries a value that keeps changing, like a number.

This also helps speed. One class change can switch many CSS rules at once. Your JavaScript doesn't have to set many inline styles one by one.

Long pages can also tell the browser to skip drawing parts that are off the screen:

Skip drawing cards that are off the screenCSS
.feed-card {  contain: layout paint style;  content-visibility: auto;  contain-intrinsic-size: 1px 180px;}
  • Line 2, contain, says the card's insides don't affect the rest of the page. So the browser can redo style, layout, and paint for one card without checking the whole page.
  • Line 3, content-visibility: auto, lets the browser skip drawing cards that are off the screen. The cards stay in the DOM.
  • Line 4, contain-intrinsic-size, gives each skipped card a stand-in size of 180px tall. Without it, the page could jump while you scroll.

Long lists: create only the rows you can see

10,000 ROWS

Think of a list with 10,000 rows. The user can see only a few rows at a time, so the page doesn't need 10,000 elements. It can create only the rows on the screen, plus a few extra. This is called virtualization. It is also called windowing or virtual scrolling.

A virtual list has three parts:

  • The viewport is the part of the list you can see.
  • Overscan is a few extra rows above and below the viewport. They are ready in case you scroll fast.
  • Two empty spacers, one at the top and one at the bottom, take up the space of all the other rows. They keep the scroll bar the same size as for the full list.

The step-through below works out which rows to create. The list has 10,000 rows, each 32px tall. The viewport is 160px tall and scrolled down 328px. There are 2 rows of overscan.

Step through the virtual list math
Step 0 of 13Ready
Your turn: follow the blue line

Step through the math for a virtual list where every row has the same height. The live 10,000-row demo uses the same math.

Running in
  1. script
Next: line 1
const rowHeight = 32;const viewportHeight = 160;const scrollTop = 328;const overscan = 2; const firstVisible = Math.floor(scrollTop / rowHeight);const lastVisible = Math.ceil((scrollTop + viewportHeight) / rowHeight) - 1;const startIndex = Math.max(0, firstVisible - overscan);const endIndex = Math.min(totalRows - 1, lastVisible + overscan);const beforeHeight = startIndex * rowHeight;const afterHeight = (totalRows - endIndex - 1) * rowHeight; console.log(startIndex + "-" + endIndex);console.log(beforeHeight + ":" + afterHeight);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
A virtual list of 10,000 rows. From top to bottom: a top spacer of 256px, extra rows 8 to 9, the viewport showing rows 10 to 15, extra rows 16 to 17, and a bottom spacer of 319424px. Only rows 8 to 17 are real elements.top spacer: 256pxempty spacerows 8-9extra rowsviewportrows 10-15on screenrows 16-17extra rowsbottom spacer: 319424pxempty space
With scrollTop at 328, only rows 8 to 17 are real elements: 6 on the screen and 2 extra on each side. Two empty spacers stand in for the other 9,990 rows.
Half a pixel of scroll shows part of one more rowPop out in the code editor (opens in a new tab)JavaScript
const rowHeight = 32;
const viewportHeight = 64;
const scrollTop = 0.5;
const firstVisible = Math.floor(scrollTop / rowHeight);
const lastVisible = Math.ceil((scrollTop + viewportHeight) / rowHeight) - 1;

console.log(firstVisible + "-" + lastVisible);

Here scrollTop is 0.5, so the viewport runs from 0.5px to 64.5px. Each row is 32px tall, so row 2 starts at 64px. A thin strip of row 2 is on the screen.

  • Line 4 finds the first row: Math.floor(0.5 / 32) is 0.
  • Line 5 finds the last row: Math.ceil(64.5 / 32) is 3, and 3 minus 1 is 2.
  • So the code prints 0-2.

A shortcut that subtracts one whole pixel would work out Math.floor(63.5 / 32), which is 1. It would miss row 2 whenever scrollTop is not a whole number.

Virtual list: 10,000 rows, only a few on the page
Row 1Index 0
Row 2Index 1
Row 3Index 2
Row 4Index 3
Row 5Index 4
Row 6Index 5
Row 7Index 6
Row 8Index 7
Row 9Index 8
Row 10Index 9
Row 11Index 10
Row 12Index 11
Top spacer
0px
Bottom spacer
319616px
Rows created
12
Try it yourself
Creating rows 0–11 (12 elements)

scrollTop is 0, so rows 0–7 are on the screen. With the extra rows, the list creates rows 0–11.

The scroll bar is as long as all 10,000 rows. Only the rows on the screen, plus a few extra, are real elements.

When every row has the same height, the math is easy: scrollTop / rowHeight, rounded down, gives the first row on the screen. When rows have different heights, the list must measure each row, remember the sizes, and fix them when a row changes. That is why real list libraries keep a saved list of where each row starts.

The table below compares three ways to show a long list. Read across a row to see what each way keeps on the page, when it fits best, and what it costs.

Three ways to show a long list
WayWhat is on the page?When does it fit best?What does it cost?
Create every rowEvery row is an element on the page.Small lists, print views, and content people must be able to search right away.With a large list, style, layout, paint, memory, and screen readers all get slower.
Virtual listOnly the rows on the screen, plus a few extra, are elements.Thousands of rows that look alike, in an app-like scrolling box.Find-in-page and some screen readers can't reach the rows that aren't on the page.
content-visibility: autoAll rows stay on the page. The browser skips drawing the ones off the screen.Long article-like pages, where find-in-page and screen readers should keep working.The page still has many elements. Use contain-intrinsic-size to keep space for the skipped parts.
Rows that aren't on the page can't be found

A row that isn't on the page can't be found with the browser's find-in-page search (Ctrl+F or Cmd+F). Some screen readers can't reach it either. For article-like pages, content-visibility: auto may be enough. For app-like data tables, virtualization can be the right choice. Then give good keyboard controls, row counts, and labels.

Find rendering work in DevTools

VERIFY

Measure first, before you change how your code is built. Chrome DevTools has two places to check drawing work:

  • The Rendering tab can flash the parts of the page that get painted again. It can also show areas where the layout jumped.
  • The Performance panel records a timeline of what the browser did. Layout work shows as purple blocks named Layout. A forced layout gets a warning named Forced reflow.
Steps to check rendering in Chrome DevToolsJavaScript
// In Chrome DevTools:// 1. More tools -> Rendering -> Paint flashing.// 2. Performance panel -> record an interaction.// 3. Look for purple "Layout" blocks.// 4. Expand warnings named "Forced reflow".

When the recording shows purple Layout blocks under your event handler, open its call stack: the list of functions that led there. Then:

  • If it points to a size read right after a write, change the order: reads first, then writes.
  • If paint takes most of the time, make the painted area smaller, or use contain to keep each part separate.
  • If JavaScript takes most of the time, use the ideas from the Main thread lesson.

Where this shows up in real code

You will meet these problems in search boxes with suggestions, panels you can drag, dashboards, editors, endless feeds, and big data tables. Here is what to use:

  • Use debounce or throttle to run heavy typing and scroll handlers less often. Still keep each handler small.
  • Use ResizeObserver and IntersectionObserver to be told when an element changes size or comes into view. Then you don't have to check it in every scroll handler.
  • Use a small renderer or a framework to change only the elements that changed. Don't replace big parts of the page with innerHTML.
  • Use classes for looks you designed, CSS variables for numbers, and transform and opacity for movement when the design allows it.
  • Use virtualization for huge app-like lists. Use content-visibility for long pages where the content should stay on the page.

The next lesson, Loading performance, is about the work before your code runs: sending and loading less code in the first place.

Common misunderstandings

  • "Moving things with transform costs nothing." It often skips layout and paint. But each layer uses memory, and too many layers make the composite step slow.
  • "will-change makes animation fast." It is only a hint, meant for a short time. Leaving it on many elements wastes memory.
  • "innerHTML is the fastest way to update." It is short to write, but the browser must read the whole HTML string again. It also throws away focus, selected text, event listeners, and anything typed into inputs.
  • "Virtualization is always best." Rows that aren't on the page can't be found with find-in-page, and some screen readers can't reach them.
  • "The browser groups all my changes for me." It groups many of them. But a size read right after a write asks for the answer now, so the browser must run layout at once.

Practice exercises

Exercise 1 · Warm-upWork out the time for one frame at 60Hz

What number does the starter code print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
console.log((1000 / 60).toFixed(1));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeCount the forced layouts

    This code uses the same pretend model as the lesson. How many forced layouts does it print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    let dirty = false;
    let forcedLayouts = 0;
    function writeStyle() { dirty = true; }
    function readLayout() {
      if (dirty) {
        forcedLayouts += 1;
        dirty = false;
      }
    }
    for (let index = 0; index < 3; index += 1) {
      writeStyle();
      readLayout();
    }
    console.log(forcedLayouts);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind which rows to create

      Every row in this list has the same height. Which rows does the list create?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const rowHeight = 32;
      const viewportHeight = 160;
      const scrollTop = 328;
      const overscan = 2;
      const firstVisible = Math.floor(scrollTop / rowHeight);
      const lastVisible = Math.ceil((scrollTop + viewportHeight) / rowHeight) - 1;
      const startIndex = Math.max(0, firstVisible - overscan);
      const endIndex = lastVisible + overscan;
      console.log(startIndex + "-" + endIndex);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeKeep reads before writes

        This code keeps reads and writes in two lists. What order does it print?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const reads = [];
        const writes = [];
        const log = [];
        reads.push(() => log.push("read"));
        writes.push(() => log.push("write"));
        reads.forEach((task) => task());
        writes.forEach((task) => task());
        console.log(log.join(" -> "));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeChoose a way to show a long list

          A customer table has 50,000 rows, all the same height. People move through it with the keyboard, like in an app. Which way creates only the rows on the screen, plus a few extra?

          Answer, then press Check. Spacing and letter case don’t matter.

            Quiz

            For each question, first ask which step the code wakes up. Then check whether a size read comes right after a write.

            DOM rendering performance quiz · 8 questionsScore: first tries count
            1. Question 1 of 8In what order does the browser usually run the steps for one frame?

              Choose an answer to see the explanation.

            2. Question 2 of 8Which read often forces layout when it comes right after a style write?

              Choose an answer to see the explanation.

            3. Question 3 of 8How many forced layouts does this model print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              let dirty = false;
              let forcedLayouts = 0;
              function write() { dirty = true; }
              function read() {
                if (dirty) {
                  forcedLayouts += 1;
                  dirty = false;
                }
              }
              write();
              read();
              write();
              read();
              console.log(forcedLayouts);

              Choose an answer to see the explanation.

            4. Question 4 of 8Which two properties are usually best for smooth movement?

              Choose an answer to see the explanation.

            5. Question 5 of 8Why put DOM writes inside requestAnimationFrame after you read sizes?

              Choose an answer to see the explanation.

            6. Question 6 of 8Which rows on the screen does this math find, before any extra rows are added?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const scrollTop = 96;
              const rowHeight = 24;
              const viewportHeight = 72;
              const first = Math.floor(scrollTop / rowHeight);
              const last = Math.ceil((scrollTop + viewportHeight) / rowHeight) - 1;
              console.log(first + "-" + last);

              Choose an answer to see the explanation.

            7. Question 7 of 8When is content-visibility: auto enough, so you don't need a virtual list?

              Choose an answer to see the explanation.

            8. Question 8 of 8What is the main warning about will-change?

              Choose an answer to see the explanation.

            Key takeaways

            • The browser draws a frame in up to five steps: JavaScript, Style, Layout, Paint, and Composite.
            • A forced layout happens when your code reads a size or position right after a write.
            • Do all reads first, then all writes. Put changes the user sees inside requestAnimationFrame.
            • Build many elements in a fragment. Use classes for designed looks and CSS variables for numbers. Don't rebuild big parts of the page with innerHTML.
            • Use a virtual list for huge app-like lists. Use content-visibility when the content should stay on the page.

            Next, Loading performance is about sending less JavaScript and loading it in a smarter way. Then the browser has less code to read and run before it can draw the page.

            CompleteFrontend Clear concepts. Working examples.