Command Palette

Search for a command to run...

Hectal
PHASE 8Intermediate ~22 min· topic 8 of 8

Topic 8.8

DOM Performance

In one line

Every DOM change can make the browser recalculate styles, redo layout (reflow) and redraw pixels (repaint); the browser batches this work until the next frame, so the way to stay fast is to batch your changes too, build big pieces off-page with a DocumentFragment, and never alternate layout reads with writes (layout thrashing).

Think of it like this

Rearranging furniture with a friend measuring the room. If you move one chair and then ask "how much space is left?", your friend must measure the whole room again. Move a chair, ask, move a lamp, ask, move the sofa, ask: the measuring takes far longer than the moving. If you first measure everything once, then move all the furniture, you only measure once. The browser is that friend, and "measuring" is called layout.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Rendering pipeline
The steps the browser takes to show a frame: style, layout, paint, composite.
Layout / reflow
Working out the size and position of every box on the page.
Paint / repaint
Drawing the pixels: text, colours, borders, images.
Composite
Putting painted layers together on screen; transform and opacity changes can be done at this step only.
Frame
One picture on screen. At 60 frames a second, each frame has about 16 ms.
Forced synchronous layout
When reading a size or position makes the browser do layout immediately, instead of waiting for the next frame.
Layout thrashing
Alternating DOM writes and layout reads, forcing many layouts where one would do.
DocumentFragment
A light container for nodes that isn't on the page; inserting it moves all its children in at once.
requestAnimationFrame
A browser function that runs your callback just before the next frame is drawn.

Step by step

01Know the pipeline

Each frame, the browser runs your JavaScript, then the rendering steps. Which steps a change triggers decides its cost.

Know the pipelinediagram
Rendering diagram…

02Spot the thrashing pattern

Here each loop turn writes a width, and the next turn reads offsetWidth, which forces layout because the previous write made it dirty.

slow.jswhole filejavascript
for (const bar of bars) {
  // read (forces layout if anything changed) ... then write (makes layout dirty again)
  bar.style.width = bar.offsetWidth * 2 + "px";
}

03Read first, then write

Collect every measurement in one pass, then make every change in a second pass. Only the first read can force a layout; the writes are batched into the next frame.

fast.jswhole filejavascript
const widths = bars.map((bar) => bar.offsetWidth); // all reads
bars.forEach((bar, i) => {
  bar.style.width = widths[i] * 2 + "px";            // all writes
});

04Build off-page, insert once

Put new elements into a DocumentFragment, then append the fragment. The page receives all of them in one insertion. parent.append(...arrayOfElements) does the same.

app.jswhole filejavascript
const frag = document.createDocumentFragment();
for (const name of names) {
  const li = document.createElement("li");
  li.textContent = name;
  frag.append(li);
}
list.append(frag); // one insertion; frag is empty afterwards

05Schedule visual work for the next frame

requestAnimationFrame runs your callback right before the browser paints. Do reads at the start of a frame and writes inside the callback, and animations stay smooth.

app.jswhole filejavascript
function moveBox(x) {
  requestAnimationFrame(() => {
    box.style.transform = `translateX(${x}px)`; // compositor-friendly
  });
}

Try it yourself

  1. 1

    Use append with many arguments

    In the first example, replace the fragment with an array: push each li into const items = [], then call b.append(...items). Predict the number of insertions reported.

  2. 2

    Record a real profile

    On your own page, make 2,000 bars and run the slow loop, then the fast one. Record both in DevTools → Performance and compare the purple Layout blocks and "Forced reflow" warnings.

  3. 3

    See paint flashing

    In Chrome DevTools, open the menu (⋮) → More tools → Rendering and tick Paint flashing. Hover over links and type in inputs on any page and watch which areas repaint.

Code & diagrams

One insertion versus a hundred New tab

A `MutationObserver` reports changes to the DOM after the script finishes. The first list was changed 100 times, the second once. Both lists were laid out only once, at the next frame, because nothing read layout in between.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Fragment is empty now: true
One by one: 100 insertion(s), 100 items
With a fragment: 1 insertion(s), 100 items
Forced layout and the read-then-write fix New tab

Both versions give the same result. The first forces a fresh layout on each turn after a write (3 bars here, thousands on a real list); the second needs only one. Reading `offsetWidth` returned 60 straight after the write, which proves the browser laid out on the spot.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

60
[ 100, 160, 240 ]
[ 100, 160, 240 ]

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Measure inside the write loop

For 2,000 rows, write row.style.height = row.offsetHeight + 10 + "px"; in a loop.

terminal
$ DevTools → Performance
── what you'll see ──
One long task with thousands of small purple Layout blocks, each marked "Forced reflow"; the page freezes for a moment.

Break #2

Animate with left instead of transform

Move a box every frame with box.style.left = x + "px" inside requestAnimationFrame.

terminal
$ the page shows
── what you'll see ──
The animation stutters on slower phones, especially while the page is busy.

Myth vs fact

Myth

Every DOM change causes an immediate reflow.

Fact

Browsers batch changes and lay out once per frame. A reflow happens early only when your code reads layout information while it's out of date.

Myth

DocumentFragment makes everything much faster.

Fact

It turns many insertions into one, which helps observers, mutation work and some engine bookkeeping, but appending to a detached element or calling append(...items) gives the same benefit. The biggest wins come from avoiding forced layouts.

Myth

The DOM is slow, so a virtual DOM is always faster.

Fact

A virtual DOM adds work on top of the real DOM. Frameworks use it for convenient, predictable updates, not because the DOM itself is slow. Careful direct DOM code is as fast or faster.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Layout scope matters: a change inside an element with contain: layout (or contain: strict) can't affect boxes outside it, so the browser can lay out less. content-visibility: auto applies containment and skips rendering of off-screen content entirely; pair it with contain-intrinsic-size so the scrollbar stays stable.

  • ▸

    Style recalculation has its own cost: changing a class on <body> may restyle the whole page, while changing a class on one card restyles only that subtree. Selectors like :has() can widen the set of elements to recheck.

  • ▸

    requestAnimationFrame callbacks run before style and layout in the frame, so writes there are batched into that frame's layout. Reading layout in a rAF callback after writing still forces layout. Background tabs pause rAF entirely, which is also what saves battery.

  • ▸

    Long tasks (over 50 ms) block input. Breaking big DOM builds into chunks with await scheduler.yield() (or setTimeout(0) where it isn't supported) lets the browser handle clicks and paint between chunks. Phase 10 explains tasks and the event loop.

Remember this

  1. 1

    To show a page, the browser runs a rendering pipeline for each frame. Style: work out which CSS rules apply to each element. Layout (also called reflow): work out the size and position of every box. Paint (also called repaint): fill in the pixels: text, colours, borders, shadows. Composite: put the painted layers together on screen. A screen usually refreshes 60 times a second (some 120), so the browser has about 16 milliseconds per frame for your JavaScript and all of this work. Miss it and scrolling and animations stutter (jank).

  2. 2

    Different changes cost different amounts. Changing geometry (width, height, padding, font size, adding or removing elements, changing text) needs style, layout, paint and composite: a reflow, which can affect many other elements because boxes push each other around. Changing only appearance (colour, background, visibility) skips layout and needs a repaint. Changing transform or opacity can often skip both and only re-composite, which is why smooth animations use them.

  3. 3

    Browsers are lazy on purpose. When your code changes the DOM, the browser doesn't lay out the page immediately; it marks the layout as dirty and waits until the current JavaScript task has finished, then does the work once before the next frame. So ten style changes in a row normally cost one layout, not ten. Batching your writes together is therefore mostly free.

  4. 4

    The problem is reading layout while it's dirty. Properties like offsetWidth, offsetHeight, offsetTop, clientWidth, scrollTop, getBoundingClientRect(), getComputedStyle(el).width and even innerText must return correct numbers right now, so the browser stops and runs layout immediately: a forced synchronous layout. Doing write, read, write, read in a loop forces a layout on every turn. This is called layout thrashing, and in a list of 1,000 items it can turn one layout into a thousand.

  5. 5

    The fixes: read everything first, then write everything (separate the phases); keep values in JavaScript variables instead of reading them back from the DOM; change a class instead of many inline styles; build large chunks off-page in a DocumentFragment (a container not in the document; appending it inserts all its children in one operation) or a detached element, then insert once; and use requestAnimationFrame(callback) to run visual updates just before the next frame. For very long lists, render only the visible rows (virtualisation) or use CSS content-visibility: auto so off-screen sections skip layout and paint.

  6. 6

    Measure, don't guess. The Performance panel in DevTools records a timeline: long purple Layout blocks with a warning triangle saying "Forced reflow" point to thrashing, and the Rendering tab can flash repainted areas green (Paint flashing). Changing a few elements never needs these tricks; they matter for large lists, animations, scrolling and code that runs on every frame. Frameworks (React, Vue, Svelte) batch DOM writes for you, but they can't stop you from reading layout in the wrong place.

Explain it without notes

01

What are reflow and repaint, and which kinds of change trigger each?

02

What is layout thrashing, and how do you fix it?

03

Why are ten style changes in a row usually not ten times as expensive as one?

04

How would you add 5,000 rows to a table efficiently?

Practice

01

Rewrite this loop so it never forces more than one layout: for (const el of items) { el.style.height = el.offsetHeight + 20 + "px"; }. Log the new heights for three 40px items.

02

Write renderNames(list, names) that replaces the contents of list with one <li> per name using a single insertion, and log how many <li> the list has after rendering ["Asha", "Ravi", "Meera"] twice.

Trade-offs

  • ↔

    Rebuilding a list from scratch is simple and fast enough for hundreds of items; updating only what changed (keyed diffing, as frameworks do) is faster for big lists and keeps focus and scroll state, but is more complex.

  • ↔

    Virtualising a very long list keeps the DOM small and fast but breaks the browser's find-in-page and can complicate accessibility; content-visibility: auto keeps everything in the DOM with smaller gains.

Done when you can

  • I can name the steps of the rendering pipeline and what triggers reflow and repaint.

  • I can spot and fix layout thrashing by reading first and writing after.

  • I can insert many elements in one operation with a fragment, append(...items) or replaceChildren.

  • I animate with transform and opacity and know what requestAnimationFrame is for.

  • I can use the DevTools Performance panel to find forced reflows.