The Cost of a Change
The table you should be able to reconstruct from first principles: which stages each common change invalidates, and why the honest answer is so often "it depends".
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.
For this specific change, which stages of the rendering pipeline does the browser actually have to re-run?
Someone interacts — hovers, drags, types, scrolls — and expects the interface to keep up. The engineer building it needs to know, before writing the line, whether it will.
Some CSS properties are fast and some are slow. Learn the list, animate only the fast ones, and the problem is solved.
The list is conditional, not absolute. transform skips layout and paint only when the element already has its own composited layer; on an element that does not, the content is redrawn in its new position like anything else (Compositing Layers).
- The list is conditional, not absolute.
transformskips layout and paint only when the element already has its own composited layer; on an element that does not, the content is redrawn in its new position like anything else (Compositing Layers). opacityhas the same condition. Fading a non-composited element repaints it every frame, which is fine for one badge and a problem for a full-screen overlay of text.- "Filters are composited" is too strong. Some filter functions can be applied by the compositor and some cannot, and a large blur is expensive on the GPU whichever path it takes.
- The same property can cost different amounts on different elements in the same document:
widthon an absolutely positioned element out of flow dirties much less geometry thanwidthon a flex item whose siblings share the space (Positioning and Stacking Contexts). - Reading is not free.
offsetHeight,getBoundingClientRect()andscrollTopforce the browser to resolve pending style and layout on the spot, producing no pixels at all (Layout Thrashing).
What is actually happening
In the browser, not in the framework.
- A change costs the earliest stage it dirties plus every stage after it. That single rule generates most of the table; the rest is knowing which stage a given property belongs to.
- Geometric properties — anything feeding box size, position in flow, or the content of a box — dirty layout. Because layout propagates, they may dirty geometry for ancestors whose size depends on content and descendants whose size depends on the parent.
- Purely visual properties — colours, shadows, borders that do not change geometry,
visibility— dirty paint but not layout, because the boxes stay where they are. transformandopacityare special only because the compositor can apply them to an already-rasterised layer. That is an optimisation with a precondition: the content must be on a layer of its own.- Layer promotion is an engine heuristic informed by hints —
will-change, 3D transforms, video, canvas, some animations. Promotion is not free: each layer costs GPU memory and a raster of its contents (Layer Explosion). - DOM structure changes dirty everything, because a new or removed box has no style and no geometry and changes what its siblings match (What a Mutation Costs).
- Reads of geometry force the pipeline forward synchronously, up to and including layout, so that the number handed back is correct. This is the one case where your own code, not the frame boundary, decides when layout runs.
What this makes the browser do
And which of it is avoidable.
- For a layout-dirtying change: recalculating style for the invalidated set, running layout over dirty subtrees, repainting the affected region, rasterising and compositing.
- For a paint-dirtying change: style, then re-recording display lists for the affected layer, then raster and composite. Cost scales with painted area and effect complexity, not with element count.
- For a compositor-only change: applying a transform matrix or an alpha value while assembling the frame, with no main-thread involvement at all in the ideal case.
- For a forced read: flushing pending invalidations and running style and layout immediately, inside your task, and then usually doing it again at the frame boundary because you wrote something afterwards.
- Avoidable work, ranked by how often it is wasted: layout triggered by a change that only needed paint; paint triggered every frame for an effect that never changes; and layers promoted for animations that never run.
The table
Read this as derivations rather than facts to memorise. Each row is the same reasoning applied to a different change: which stage does this dirty first, and what therefore follows? The maybe entries are the interesting ones — they are where the answer depends on the element, the page or the engine, and they are where the folklore is usually wrong.
The composite column is yes almost everywhere, which is not a mistake: any visible change ends with a frame being assembled. It is only interesting when it is the *only* yes in the row, because that is the compositor-only path everyone is aiming for.
| Change | style | layout | paint | composite | Why |
|---|---|---|---|---|---|
| Change `width` or `height` on an in-flow element | yes | yes | yes | yes | Geometry changed. Siblings may move, and ancestors sized by their content may resize, unless a containment boundary stops the propagation (CSS Containment). |
| Change `margin` or `padding` | yes | yes | yes | yes | Same as size: these participate in the box model, so the boxes around them move too (The Box Model). |
| Change `top` / `left` on a positioned element | yes | yes | yes | yes | Still layout, unlike transform. On an absolutely positioned element the dirty region is smaller because it is out of flow, but layout runs for it regardless. |
| Change `font-size` | yes | yes | yes | yes | Inherited, so the invalidated set includes descendants, and text metrics change so every line box is recomputed. |
| Change `color` | yes | no | yes | yes | Inherited — the style stage touches descendants — but nothing moves, so layout is skipped entirely. |
| Change `background-color` | yes | no | yes | yes | Pure paint. Cost tracks the painted area, not the number of elements. |
| Change `box-shadow` (large blur radius) | yes | no | yes | yes | No geometry, but blur is one of the more expensive things to rasterise, and the cost scales with radius and area every frame it changes. |
| Set `visibility: hidden` | yes | no | yes | yes | The box keeps its space, so geometry is unchanged. The subtree does leave the accessibility tree. |
| Toggle `display: none` ↔ `block` | yes | yes | yes | yes | Boxes are destroyed and recreated; on re-show, style and layout run for the whole subtree as if it were new. |
| Animate `transform` on an element with its own composited layer | yes | no | no | yes | The compositor applies a new matrix to already-rasterised content. This is the path everyone means by "cheap animation" — and it requires the layer to exist. |
| Animate `transform` on an element with no layer of its own | yes | no | yes | yes | No layout — transforms never reflow — but the content must be redrawn in its new position within the layer it shares. |
| Animate `opacity` on a composited element | yes | no | no | yes | An alpha value applied at composite time. Same precondition as transform. |
| Animate `opacity` on a non-composited element | yes | no | yes | yes | The subtree is repainted at the new alpha every frame; on a large overlay of text this is the whole frame budget. |
| Animate `filter: blur()` | yes | no | maybe | yes | Some filter functions can be applied by the compositor and some cannot, per engine. Either way a large blur is expensive per frame — the question is only which processor pays. |
| Add `will-change: transform` | yes | no | maybe | yes | Usually triggers promotion: a new layer, GPU memory, and an initial raster of the contents. Beneficial before an animation, a standing cost if left on. |
| Append a DOM node | yes | yes | yes | yes | A new box needs style and geometry, and sibling selectors may invalidate the elements after it (What a Mutation Costs). |
| Remove a DOM node | yes | yes | yes | yes | The space it occupied has to be redistributed, and following siblings may match different rules than before. |
| Change the text inside an element | maybe | yes | yes | yes | Style may not need recomputation at all, but line breaking and box sizing do — which is why a live-updating counter can be surprisingly expensive in a flex row. |
| Toggle a class matched only as a key selector | yes | maybe | maybe | yes | One element recalculated; what follows depends entirely on which declarations that rule contains. |
| Toggle a class on `<body>` used by descendant selectors | yes | maybe | maybe | yes | The style cost is the invalidated set, which can be the document. Layout follows only if a winning declaration is geometric (Style Invalidation). |
| Set a CSS custom property on `:root` | yes | maybe | maybe | yes | Substitution runs per inheriting consumer. If the value feeds a length, layout follows for all of them; if it feeds a colour, only paint does (Custom Properties). |
| Read `offsetHeight` after writing a style | yes | yes | no | no | Forced synchronous layout: style and layout run immediately, inside your task, and produce no pixels. In a loop, once per iteration. |
| Call `getBoundingClientRect()` | yes | yes | no | no | Same as above — the browser must flush pending invalidation to return a correct rectangle. |
| Scroll a container the compositor owns | maybe | no | maybe | yes | Scrolling moves already-rastered tiles. Style appears when :hover targets change or scroll-driven effects run; paint appears for newly exposed content (Scroll and Input Latency). |
| Scroll with a non-passive listener that reads layout | maybe | yes | maybe | yes | The scroll now depends on the main thread: the compositor must wait for the handler, and the handler forces layout. This is the classic scroll-jank recipe (Passive Listeners). |
| Add a stylesheet at runtime | yes | yes | yes | yes | New rules mean rebuilt indexes and no safe assumption about what matched before; treat it as a document-wide invalidation. |
| A web font finishes loading | yes | yes | yes | yes | Text metrics change for every element using the family, which is why late fonts produce layout shift after content is already readable (Images and Fonts). |
caveat Every verdict here is conditional on what else is on the page and on the engine. Layer promotion, filter compositing and custom property handling are heuristics that differ between Blink, Gecko and WebKit and change between versions; containment, positioning and stacking context change how far layout propagates; and a maybe almost always resolves by asking "which declarations did that rule actually contain?". Use the table to form a hypothesis, then read a trace in the browsers you support.
Why the honest answer is "maybe"
Five conditions decide most of the maybe entries, and they are worth knowing by name. First: does the element have its own composited layer? Second: is the changed property geometric, and does anything around it depend on that geometry? Third: is the property inherited, so that the style stage reaches descendants? Fourth: is there a containment or size boundary that stops propagation? Fifth: which engine, in which version, with which heuristics?
That last one is not a cop-out. Promotion criteria and filter compositing have all changed across releases in every engine, in both directions. A lesson that fixes them into a rule would be teaching something that will be wrong, and confidently. What does not change is the reasoning: find the earliest dirty stage, then check the preconditions for skipping the ones after it.
How do you keep a per-frame update off the layout and paint stages?
when The element is a video, a canvas, a 3D-transformed element, or already the target of a compositor-driven animation — verify with layer tooling rather than assuming.
cost Almost nothing per frame. But the precondition is real: if you are wrong about the layer, you have chosen the paint path and will see no improvement.
when A specific animation is about to start and you control its start and end.
cost One layer of GPU memory plus an initial raster. Cheap once, expensive if you apply it broadly or never remove it (Layer Explosion).
when The animation is declarative — a known start, end and easing — rather than driven by continuous input.
cost Less control per frame, and the engine decides whether it can run it off the main thread. In exchange it keeps running while the main thread is busy.
when The design genuinely requires reflow — a list expanding, a column resizing — and it happens once per interaction, not once per frame.
cost Layout for the affected subtree per frame of the transition. Often perfectly acceptable; the mistake is paying it on every frame by accident.
when The state change is discrete: a class toggle with a CSS transition expresses it, and the browser interpolates.
cost You give up frame-by-frame control. This is usually the right default and the one people skip past on the way to a requestAnimationFrame loop.
Reading is a write in disguise
The most reliable way to spend a frame producing nothing is to interleave layout reads with style writes. Every read must return a correct answer, so it flushes whatever is pending; every write invalidates again. Two hundred elements handled this way means two hundred layouts inside a single handler, none of which reaches the screen.
The fix does not require a library. Collect measurements first, compute, then apply — and if the work must span frames, do the reads in one frame and the writes in the next. The pattern is worth internalising because frameworks do not protect you from it: a measurement read inside an effect flushes everything the framework has queued up to that moment (Layout Thrashing).
| Change | style | layout | paint | composite | Why |
|---|---|---|---|---|---|
| `el.offsetWidth` / `offsetHeight` / `offsetTop` | yes | yes | no | no | Flushes pending style and layout so the integer is correct. No frame is produced. |
| `el.getBoundingClientRect()` | yes | yes | no | no | Same flush, sub-pixel result. Also reflects transforms, which offsetTop does not. |
| `getComputedStyle(el).color` | yes | no | no | no | Style must be current; a non-geometric property does not require layout. |
| `getComputedStyle(el).height` | yes | yes | no | no | A resolved length is a used value, so layout has to run — the same call is cheap or expensive depending on the property you ask for. |
| Reading `el.scrollTop` | yes | yes | no | no | Scroll offsets are geometry. Writing scrollTop does not force layout, but reading it does — which is why scroll handlers thrash so easily. |
| `ResizeObserver` / `IntersectionObserver` callback data | no | no | no | no | The measurement was taken by the engine during the rendering steps and handed to you. This is the point of these APIs: geometry without a forced flush. |
caveat Which properties force layout is engine-specific in the details — the broad categories agree across Blink, Gecko and WebKit, but individual properties have moved. If a read is on a hot path, confirm it in a trace rather than trusting a list.
for (const card of cards) {
const h = card.offsetHeight // forces layout
card.style.height = `${h + 8}px` // invalidates it again
}
// n reads, n layouts, one frame, nothing painted until the endconst heights = cards.map((c) => c.offsetHeight) // one layout
for (let i = 0; i < cards.length; i++) {
cards[i].style.height = `${heights[i] + 8}px` // no reads here
}
// 1 layout, n writes, resolved once at the frame boundaryLayout is resolved once per frame unless something demands an answer sooner. The first version demands one on every iteration, so its cost is linear in elements *times* the cost of a full layout pass; the second pays for exactly one. The output is identical.
How to build it
Most important first.
- Decide which stage you can afford *before* choosing the property. For a per-frame update, the target is composite; for a one-off state change, layout is usually fine and often unavoidable.
- Batch reads then writes, per frame, so the browser resolves layout once. A layout read followed by a write followed by another read is the canonical way to spend a frame producing nothing.
- Promote deliberately and temporarily. Add
will-changebefore an animation starts and remove it when it ends; leaving it on is a standing memory cost for a benefit you are not using (Cheap and Expensive Animation). - Animate with CSS transitions or the animation API where you can, so the engine knows the whole animation up front and can run it off the main thread; a per-frame JavaScript write cannot be handed over in the same way.
- Shrink the painted area rather than the number of elements when paint is the bottleneck: a smaller shadow, a smaller blurred region, a solid background instead of a gradient behind moving content.
- Measure the specific case. This lesson gives you the shape of the answer; only a trace on the device you care about gives you the answer (Measure Before Optimising).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Compositor-only animation is still animation: honour reduced-motion, because the objection is to the movement, not to its cost (Contrast, Colour and Motion).
- A change that skips layout and paint also skips the accessibility tree — moving something with
transformcommunicates nothing to a screen reader, so state conveyed by position needs a semantic counterpart (Semantics Before ARIA). opacity: 0leaves content in the accessibility tree and in the tab order. Elements faded out to "hide" them are still reachable, still announced, and a common source of focus disappearing off screen (Focus Management).- Forced synchronous layout inside an input handler delays the frame that would have moved focus or updated a live region, so the announcement arrives after the visual change rather than with it (Live Regions and Announcement).
- Layout-heavy interactions are worst for the users least able to absorb them: screen magnifier users following a caret and switch users depending on predictable timing both experience delay as loss of position.
What can go wrong
- Animating
topandleftonce per frame, so every frame includes layout for a subtree that only needed to move. - Switching to
transformand seeing no improvement, because the element was never promoted and paint was the cost all along. - Applying
will-change: transformto every card in a list to "make scrolling smooth", producing hundreds of layers, exhausting GPU memory, and making scrolling worse. - A resize handler that reads
getBoundingClientRect()for each of 200 elements and then sets a style on each, interleaved — layout runs 200 times inside one handler. - The mitigation failing: moving an animation to the compositor while leaving a
box-shadowwith a large blur on the same element, so every frame still rasterises the expensive part. - Optimising a change that happens once per interaction as if it happened once per frame, and paying complexity for nothing.
- A measurement read in an effect can observe geometry from before an async data update lands, so the layout you measured is not the layout the user sees.
- Web fonts and images resolving mid-interaction change geometry underneath an animation that was already running, forcing layout in frames you had reasoned were compositor-only.
- Layer promotion is not instantaneous: an element promoted at the start of an animation may spend the first frames being rasterised, which is why the first frame of a
will-change-triggered animation is often the worst one.
- Frame timing is a side channel. Browsers reduce timer resolution and add jitter precisely because "how long did this take to render" can leak cross-origin state (The Browser Security Model).
- Cross-origin iframes do not expose their layout or their pixels; you can size them, and nothing more. Any technique that appears to read their content is a bug being reported, not an API.
- User-supplied content can be crafted to be expensive to lay out — enormous nesting depth, gigantic text runs, thousands of nodes — which makes rendering cost an input-validation concern (Sanitization and Trusted HTML).
- Attacker-controlled style can move interface elements over one another. Cost is not the only reason to care which stage a change reaches; a transform that repositions a confirm button is a clickjacking primitive (Clickjacking and Framing).
- "
transformis always cheap." It is cheap when the compositor can apply it to an existing layer. On a non-promoted element it costs paint, and promoting everything to make it cheap costs more than it saves. - "
opacitynever triggers paint." Only on a composited element. Fading a large non-composited subtree repaints it every frame. - "Layout is the expensive one." Layout is *usually* the expensive one. Paint dominates on shadow-heavy and filter-heavy interfaces, and composite dominates when there are too many layers.
- "
will-changemakes things faster." It tells the engine to prepare, which usually means promotion. Preparation has a cost, and permanent preparation is permanent cost (Layer Explosion). - "If devtools shows no layout, the change was free." It was free of layout. Style recalculation over a large invalidated set can eat a frame with no layout event anywhere in the trace (Style Invalidation).
- "The framework batches my DOM writes, so I cannot cause forced layout." Frameworks batch their own writes; a
getBoundingClientRect()in an effect still reads through everything pending at that moment (What a Component Costs to Render).
Measuring it, and what changes in the field
- Record an interaction and read which stage events appear: style only, style plus layout, or neither — the trace answers this question directly and folklore does not (Debugging Rendering and Jank).
- Paint flashing shows what actually repainted. An animation you believe is compositor-only and that flashes green every frame is not compositor-only.
- Layer tooling shows how many layers exist, why each was promoted, and how much memory they hold. It is the only honest check on a
will-changedecision. - Devtools flag forced synchronous layout explicitly, usually with the stack that triggered it — that is the fastest bug in this module to find and fix.
- In the field, this is interaction latency: the tail of the distribution is where layout-heavy interactions live, and the median will not show them (Interaction Responsiveness, Vitals in the Field).
- On a slow device the gap between stages widens: a layout-driven animation that merely looks slightly soft on a laptop drops half its frames on a mid-range phone.
- On a high-density display, paint and raster cost more per CSS pixel, so paint-bound interfaces degrade fastest on the best screens.
- On a large DOM, layout propagation is the dominant term, and the same change costs more purely because there is more geometry that could depend on it.
- Under memory pressure, promoted layers are a liability: the compositor may have to drop and re-rasterise tiles, turning a "free" animation into a stuttering one.
- On a device that is thermally throttled — a phone that has been recording video, a laptop on battery saver — GPU-side costs rise without any change in the code.
- Designing for the compositor constrains what you can animate. Size, layout position, colour transitions across a gradient and text reflow are all legitimate design choices that cannot be expressed as a transform.
- Promotion trades main-thread work for GPU memory. That is a good trade a few times per page and a bad one a few hundred times.
- Batching reads and writes means more coordination in application code — a place to collect measurements, a place to apply them — and frameworks that hide the DOM make it harder, not easier, to see where the reads happen.
- Reasoning about
maybeis genuinely harder than memorising a list. The compensation is that it stays correct when engines change their heuristics, which the list will not.
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 core rule — a change costs the earliest stage it dirties plus everything downstream — follows from the pipeline's dependency order and holds in every engine; so does the fact that geometric properties dirty layout and colour properties do not.
- ENGINE-SPECIFICWhich elements get their own composited layer, which filter functions the compositor can apply, and whether a registered custom property can be animated off the main thread are per-engine heuristics that change between versions. Blink, Gecko and WebKit each promote on different criteria, so an animation that is compositor-only in one browser can be paint-bound in another on identical markup.
- DEVICE-SPECIFICThe same verdict has very different consequences on different hardware: on a high-density phone display, paint and raster cost several times what they do on a standard-density desktop screen, and GPU memory limits make layer promotion a real constraint rather than a theoretical one.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — how you would assert that a hot interaction still avoids layout, given that the assertion has to survive engine heuristic changes that are not regressions in your code.