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;
transformandopacitychanges 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.
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.
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.
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.
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 afterwards05Schedule 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.
function moveBox(x) {
requestAnimationFrame(() => {
box.style.transform = `translateX(${x}px)`; // compositor-friendly
});
}Try it yourself
- 1
Use append with many arguments
In the first example, replace the fragment with an array: push each
liintoconst items = [], then callb.append(...items). Predict the number of insertions reported. - 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
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
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
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 itemsBoth 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
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.
Break #2
Animate with left instead of transform
Move a box every frame with box.style.left = x + "px" inside requestAnimationFrame.
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(orcontain: strict) can't affect boxes outside it, so the browser can lay out less.content-visibility: autoapplies containment and skips rendering of off-screen content entirely; pair it withcontain-intrinsic-sizeso 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. - ▸
requestAnimationFramecallbacks 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()(orsetTimeout(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
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
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
transformoropacitycan often skip both and only re-composite, which is why smooth animations use them. - 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
The problem is reading layout while it's dirty. Properties like
offsetWidth,offsetHeight,offsetTop,clientWidth,scrollTop,getBoundingClientRect(),getComputedStyle(el).widthand eveninnerTextmust 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
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 CSScontent-visibility: autoso off-screen sections skip layout and paint. - 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
What are reflow and repaint, and which kinds of change trigger each?
What is layout thrashing, and how do you fix it?
Why are ten style changes in a row usually not ten times as expensive as one?
How would you add 5,000 rows to a table efficiently?
Practice
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.
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: autokeeps 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)orreplaceChildren.I animate with
transformandopacityand know whatrequestAnimationFrameis for.I can use the DevTools Performance panel to find forced reflows.