DOMGENERALENGINE-SPECIFICSIMPLIFIED

What a Mutation Costs

Which changes invalidate style, which force layout, which repaint and which the compositor can absorb — and why the honest answer to most of them is "it depends what else is on the page".

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

I changed one property on one element. How much work did I just ask the browser to do?

The user intent

A person drags a slider, opens a menu, or watches a list update. They want the interface to keep up with them — to respond within a frame, not a beat later.

The obvious build

A DOM write is a DOM write. They all cost about the same, so the way to be fast is simply to do fewer of them.

Why it breaks

Changing transform on a promoted element and changing top on the same element look identical on screen and differ by everything: one is handed to the compositor, the other re-runs layout for its containing block (Cheap and Expensive Animation).

How it breaks in a real browser
  • Changing transform on a promoted element and changing top on the same element look identical on screen and differ by everything: one is handed to the compositor, the other re-runs layout for its containing block (Cheap and Expensive Animation).
  • One hundred writes in a row can be cheaper than two writes with a layout read between them, because the read forces the browser to complete work it had deliberately deferred (Layout Thrashing).
  • A class added to <body> can be more expensive than a hundred writes to a leaf, because the invalidation scope is decided by your selectors and not by the element you touched (Selector Matching Cost).
  • innerHTML = sameMarkup costs the full destruction and rebuild of a subtree, plus every listener, focus position and selection inside it — for zero visual change (Node Identity Across Updates).
  • "Fewer DOM writes" is not actionable advice. "This write invalidates layout for the whole scroll container, and this one does not" is.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A mutation does not do rendering work. It marks things dirty: this element's style is stale, this subtree needs layout, this region needs repainting. The work happens later, at the rendering opportunity (The Rendering Opportunity).
  • Style recalculation decides which elements need their computed values redone. The scope is decided by which selectors could now match differently — which is why selector shape is a performance property (Style Invalidation).
  • Layout runs when geometry inputs changed. It is not per-element: changing one element's width re-lays-out its containing block and can cascade to siblings and ancestors, unless a containment boundary stops it (CSS Containment).
  • Paint produces drawing commands for regions whose visual output changed. Layout changes always imply paint; some paint changes (a colour, a shadow) imply no layout at all.
  • Composite combines layers into a frame, potentially on another thread. Changes to transform and opacity on an element that already has its own layer can be applied here without touching the main thread's style, layout or paint (Compositing Layers).
  • The pipeline is a prefix relationship: invalidating an early stage costs every later stage. That is the whole reason the stage a change enters at is the number that matters (The Rendering Pipeline).
  • Forced synchronous layout inverts the schedule. Reading offsetTop, getBoundingClientRect, scrollHeight or getComputedStyle while layout is dirty makes the browser compute it immediately, inside your task, before returning the value.

What this makes the browser do

And which of it is avoidable.

  • Walking invalidated subtrees to recompute style. On a large tree with descendant-heavy selectors this can dominate a frame on its own.
  • Re-running layout for the invalidated containing blocks. Layout is the stage that most often exceeds the frame budget on real pages (The Frame Budget).
  • Re-rasterising the damaged regions, and re-uploading textures to the GPU process for the layers that changed.
  • Updating the accessibility tree for the changed nodes and notifying platform assistive technology (The Accessibility Tree).
  • Avoidable: work you asked for by reading layout mid-write, by mutating properties whose values did not change, and by replacing markup that was already correct.

From write to frame

The important thing about this sequence is that your mutation only participates in the first step. Everything after it is the browser deciding, at the rendering opportunity, how much of the pipeline your change made stale.

That deferral is also what makes forced synchronous layout so expensive: reading a layout property pulls steps two and three forward into the middle of your task, and if you do it in a loop the browser performs them once per iteration (Yielding and Scheduling).

What happens after a DOM write
  1. 1
    Mutate

    Your write lands on the node object and sets invalidation flags on it and, depending on selectors, on ancestors or the whole document.

    fails by Writing a value identical to the current one, which some engines short-circuit and some do not.

  2. 2
    Recalculate style

    Recomputes computed values for the invalidated set and compares them with the previous values to decide what to invalidate downstream.

    fails by Broad selectors or a class on a root element widening the invalidated set to the entire document (Specificity).

  3. 3
    Layout

    Recomputes geometry for boxes whose inputs changed, propagating up to the containing block and down through descendants.

    fails by A change near the root with no containment boundary, so the whole document re-lays-out.

  4. 4
    Paint

    Produces ordered drawing commands for the damaged regions and assigns them to layers.

    fails by A large damaged region on a high-DPI display, where raster cost scales with device pixels.

  5. 5
    Composite

    Combines layers into a frame, on the compositor thread, and hands it to the display.

    fails by Too many layers to composite within the frame budget, or a layer too large to keep in GPU memory.

A change that enters at composite skips the three most expensive steps. A change that enters at style pays for all of them.

The cost table

This is the device the domain is built around, and the maybe column is the honest part of it. Very few DOM changes have a fixed cost; most have a cost that depends on which properties a class actually sets, whether an element already has its own layer, and how broad your selectors are.

Read a row as a question to ask rather than a verdict to apply. "Does this class change any geometry property?" and "does this element already have a layer?" are answerable in thirty seconds in devtools, and they turn every maybe into a yes or a no for your specific page.

What a given change invalidates
ChangestylelayoutpaintcompositeWhy
`el.textContent = "Saved"`noyesyesyesNo selector could match differently, so style is untouched — but text metrics changed, so the line box, the containing block and anything sized by content must be measured again.
`el.classList.add("is-active")` setting only `color`yesnoyesyesThe browser must recompute style to find out what changed. Having compared old and new computed values, it can see no geometry input moved and skips layout.
`el.classList.add("is-open")` setting `height`yesyesyesyesIdentical code to the row above, entirely different cost. The class name tells you nothing; the declarations inside it tell you everything.
`el.style.transform = "translateX(8px)"`yesnomaybeyesInline style writes always recompute this element's style. Paint is skipped only if the element already has its own compositing layer; otherwise its layer's contents are re-rastered.
`el.style.opacity = "0.5"`yesnomaybeyesSame shape as transform. Opacity below 1 usually creates a stacking context, which can change how much is grouped into one layer (Positioning and Stacking Contexts).
`el.style.top = y + "px"` in a rAF loopyesyesyesyesThe visual result can be identical to a transform animation and the cost is the entire pipeline, every frame. This is the single most common cause of a janky animation.
`container.appendChild(node)`yesyesyesyesA new box in the flow. Siblings after it may move; the containing block may resize; :nth-child, :last-child and sibling combinators can invalidate neighbours you did not touch.
`container.innerHTML = sameMarkup`yesyesyesyesThe full cost for zero visual change, plus destroyed listeners, lost focus, lost selection, reset scroll and restarted transitions. The browser cannot detect that the output is identical (Node Identity Across Updates).
`document.body.classList.add("dark")`yesmaybeyesyesInvalidation scope is decided by which selectors descend from the changed element, not by the element itself. A theme class on the root is a document-wide style recalculation by design.
Reading `el.offsetHeight` after a writeyesyesnonoThe read changes nothing. It forces the browser to run style and layout *now*, inside your task, instead of at the rendering opportunity — and it does so on every iteration of a loop (Layout Thrashing).
`el.remove()` inside a `contain: strict` subtreeyesmaybemaybeyesContainment tells the browser that nothing inside can affect the size or paint of anything outside, so invalidation stops at the boundary instead of propagating to the document (CSS Containment).
`el.setAttribute("aria-expanded", "true")`maybenononoCosts nothing visually unless a selector matches on the attribute — but it does invalidate the accessibility tree, which is the entire point of writing it (The Rules of ARIA).

caveat Every row is context-dependent, and the maybe values are not hedging — they are the answer. Whether transform skips paint depends on whether the element already has a layer; whether a class costs layout depends on which properties it sets; whether style recalculation is scoped or document-wide depends on your selectors. Use the table to know which question to ask, then confirm the answer for your page in a Performance recording (Debugging Rendering and Jank).

The read that costs more than the writes

SIMULATEDThe bars are produced from the invalidation model described in this lesson, not from a profiler run. The relative shape — forced layout repeating per element on the left, once on the right — is what transfers; the widths are illustrative and would differ by device, tree size and layout mode.

Layout thrashing is the clearest demonstration that mutation cost is about scheduling and not about counting. The two versions below perform exactly the same reads, exactly the same writes, and differ only in their order — and one of them runs layout once while the other runs it once per element.

The timeline is schematic and in relative units. The shape is the lesson: in the interleaved version, layout appears between every pair of operations, and none of that layout produces a frame the user ever sees.

Interleaved versus batched, four rowsrelative units — a shape produced from the invalidation model, not a measurement
Interleaved: read 1 → forced layout
Interleaved: write 1
Interleaved: read 2 → forced layout
Interleaved: write 2
Interleaved: read 3 → forced layout
Interleaved: write 3 + read 4 → forced layout
Batched: all four reads
Batched: all four writes
Batched: layout at the rendering opportunity
Batched: paint + composite
  • Interleaved: read 1 → forced layoutThe first read flushes whatever was already dirty.
  • Interleaved: read 2 → forced layoutThe write above dirtied layout again, so this read pays for it again.
  • Interleaved: write 3 + read 4 → forced layoutCost grows linearly with the list. This is why it only shows up in production data.
  • Batched: all four readsOne flush, then three free reads — nothing wrote in between.
  • Batched: all four writesDirties layout once, and nothing reads it back.

The interleaved version produced no extra pixels for any of that extra layout. Every forced layout in it was thrown away by the next write.

Same work, different schedule
Interleaved read and write
for (const row of rows) {
  // read: layout is dirty from the previous write,
  // so the browser must run it now, synchronously
  const h = row.offsetHeight
  row.style.height = h * 2 + 'px'   // write: dirty again
}
Read phase, then write phase
// phase 1 — read everything. Layout runs at most once.
const heights = rows.map((row) => row.offsetHeight)

// phase 2 — write everything. Nothing reads, so nothing forces.
rows.forEach((row, i) => {
  row.style.height = heights[i] * 2 + 'px'
})

A layout read returns a value that must be correct *right now*, so the browser cannot defer pending layout past it. Separating the phases means there is exactly one dirty-to-clean transition instead of one per element, which turns n forced layouts into one scheduled layout.

How to build it

Most important first.

  • Know which stage your change enters at before you write it. That single question resolves most animation and interaction-latency arguments without a profiler (The Cost of a Change).
  • Separate reads from writes. Read everything you need, then write everything — never interleave, and never read layout inside a loop that writes (Layout Thrashing).
  • Animate transform and opacity where the effect allows it, and understand *why*: those two can be applied by the compositor without re-running layout or paint. This is a mechanism, not a blanket recommendation — promoting an element has its own memory cost (Layer Explosion).
  • Bound the invalidation scope. contain, content-visibility and a scroll container with its own stacking context all give the browser permission to stop propagating work outward (CSS Containment, content-visibility).
  • Mutate the smallest thing that changed. Toggling one class beats rewriting a style attribute; rewriting one text node beats replacing a subtree.
  • Batch structural insertions — build in a DocumentFragment or off-tree and insert once — so the tree is walked once rather than per node.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Every mutation also invalidates the accessibility tree, and that update is delivered asynchronously across a process boundary. A rapid sequence of mutations can leave assistive technology reading a tree that no longer matches the screen (The Multi-Process Browser).
  • Mutations that move focusable elements — an expanding panel, a virtualised list recycling rows — can move or destroy the focused element. Focus is a property of a node object; if the node goes, focus falls back to the document (Focus Management).
  • A live region announces its *changes*, so a mutation strategy that replaces the whole region on every update makes a screen reader re-announce everything. Mutating only the changed row is an accessibility decision as much as a performance one (Live Regions and Announcement).
  • Cheap-to-composite is not the same as safe to animate. Movement, parallax and large transitions must respect prefers-reduced-motion regardless of what stage they cost (Contrast, Colour and Motion).
  • Toggling display changes accessibility-tree membership; toggling opacity or moving offscreen does not. Two "hide" implementations with the same visual result have completely different semantics (Semantics Before ARIA).

What can go wrong

Failure modes
  • A read-write loop over a list, which turns an O(n) update into n forced layouts. It is invisible in code review and unmistakable in a Performance recording (Debugging Rendering and Jank).
  • Promoting everything to its own layer to "make it fast", which trades main-thread time for GPU memory and can make scrolling worse on a low-memory device (Layer Explosion).
  • Animating a property the compositor cannot handle — width, top, box-shadow — inside a requestAnimationFrame loop, producing a full layout and paint on every frame.
  • Assuming a framework protects you. Frameworks batch and diff, which reduces the *number* of mutations; the cost of the mutations that do reach the DOM is unchanged (What a Component Costs to Render).
  • Optimising the wrong stage: shaving script time from a frame whose cost was 80% layout. Measure which stage before choosing a fix (Measure Before Optimising).
Security
  • The parsing sinks are the dangerous mutations. innerHTML, outerHTML, insertAdjacentHTML and document.write turn a string into nodes; textContent and createElement plus append do not (Sanitization and Trusted HTML).
  • Attribute writes are sinks when the attribute is a URL, an event handler or style. Writing href or src from untrusted input allows javascript: and data: URLs; writing style allows exfiltration through background image URLs (Cross-Site Scripting).
  • A CSP with a strict script-src blocks the execution half of an injection but not the injection: markup that changes what a form submits to, or that overlays a fake control, needs no script at all (Clickjacking and Framing).
  • Trusted Types, where available, turn "any string can become markup" into a typed boundary that fails loudly at the sink. It is the only mitigation that survives a large codebase with many contributors (Content Security Policy).
Misreads
  • "transform is always cheap." It is cheap when the element already has its own compositing layer and nothing else forces paint. On an unpromoted element in a complex stacking context it can still repaint (Compositing Layers).
  • "will-change makes things fast." It asks the browser to promote an element in advance. Applied broadly it consumes GPU memory and can make everything slower (Layer Explosion).
  • "Reading from the DOM is free because it does not change anything." Reading a *layout* property while layout is dirty is one of the most expensive things you can do.
  • "Fewer, bigger mutations are always better." One innerHTML replacement is one mutation and can be far more expensive than a hundred targeted writes.
  • "The virtual DOM makes mutation cost go away." It changes how many mutations happen. What each one costs the browser is identical (Reactivity Models).

Measuring it, and what changes in the field

How you would see this
  • A Performance recording shows the stages by name: Recalculate Style, Layout, Pre-Paint, Paint, Composite Layers. The stage that dominates is the one to attack (Debugging Rendering and Jank).
  • Forced synchronous layout appears as a distinctly labelled warning attributed to the line that read the layout property. It is one of the few performance problems devtools names outright.
  • Paint flashing and layer borders overlays show which regions actually repaint and which elements have their own layer — usually more of both than expected.
  • Interaction latency in the field tells you whether any of this mattered to a real person on a real device (Interaction Responsiveness).
Slow device, slow network, large data, old tab
  • On a slow device the same invalidation costs several times more, and the difference between entering the pipeline at composite versus at layout is the difference between a smooth drag and a stuttering one.
  • On a large tree, style and layout scale with the number of affected elements, so the same class toggle is free on a small page and a frame drop on a dense table.
  • On a high-DPI display, paint and raster cost scale with device pixels, so a repaint of a large region is materially more expensive than the CSS pixel dimensions suggest (The Viewport and Device Pixels).
  • In a scroll container, every frame is already doing work. A mutation that is affordable while idle can miss frames when it lands during a fling (Scroll and Input Latency).
What this costs
  • Compositor-friendly animation constrains what you can animate. Some effects genuinely require layout, and faking them with transform produces distortion that is worse than the frame cost.
  • Containment and content-visibility buy bounded invalidation and cost you correctness at the boundary: sizes must be provided or content jumps, and find-in-page and anchor navigation behave differently inside skipped subtrees.
  • Batching reads and writes makes code less readable — the natural way to write it is the interleaved way. The discipline is worth it only where it is measurably hot, and a comment saying why is part of the fix.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe stage ordering — style, then layout, then paint, then composite — and the fact that invalidating an earlier stage costs the later ones is common to every modern engine, because it follows from what each stage consumes.
  • ENGINE-SPECIFICWhich properties can be handled purely by the compositor, and when an element gets its own layer, are implementation decisions. Blink and WebKit promote on different heuristics and Gecko uses a different layerisation model entirely, so a change that skips paint in one browser may not in another. Verify per engine rather than memorising a property list.
  • SIMPLIFIEDReal engines subdivide further — pre-paint, property trees, partial invalidation, tile-level raster — and can skip stages for offscreen content. The four-stage model predicts relative cost correctly and understates how much the browser already optimises on your behalf.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Computer Architecturecache-lines
Domains that do not exist yet
  • Software Design — batching reads and writes is the same shape as separating queries from commands, and the reason is the same: interleaving them makes the cost of each depend on the other.