What Does This Change Cost?
Pick a change and see which stages of the rendering pipeline have to run again. This is the question behind most frontend performance advice, and knowing the answer turns a list of rules into something you can derive.
“Maybe” is a real answer and it appears a lot. Whether a change costs layout or only compositing depends on what else is on the page — whether the element is on its own layer, whether containment is applied, and what else changed in the same frame. A table that answered every cell yes or no would be teaching a certainty the pipeline does not have. Engines also differ here, and the same change can be free in one and not in another.
Recompute which declarations win for the affected elements and what their computed values are.
Compute geometry — size and position — for everything the change can affect, which is often more than the element you changed.
Record the drawing commands for the affected area and rasterise them.
Assemble the layers into the frame that is handed to the screen.
Pure gain: the ratio is known before the bytes arrive, so the first layout is already correct and the second one never happens.
caveat Every maybe here resolves against the rest of the page. An image inside a container with a fixed size and contain: layout cannot propagate a layout invalidation outwards at all, which changes several of these rows (CSS Containment).
The whole table
Every change the lessons cost out, in one place. Read down a column to see which stage is the expensive one for you.
| Change | style | layout | paint | composite | Why |
|---|---|---|---|---|---|
| Adding `width` and `height` attributes | no | no | no | no | Pure gain: the ratio is known before the bytes arrive, so the first layout is already correct and the second one never happens. |
| An image arriving with no declared ratio | no | yes | yes | maybe | The box grows from zero to its intrinsic size, invalidating layout for everything after it in the flow. This is the mechanism of media-driven layout shift. |
| Swapping `src` on a visible image | no | maybe | yes | maybe | Layout only if the intrinsic ratio changed or no ratio was declared. Paint always; a new decode and a new texture upload. |
| `loading="lazy"` on an offscreen image | no | no | no | no | Defers the request, the decode and the texture entirely — as long as space was reserved, so its later arrival shifts nothing. |
| A playing `<video>` | no | no | no | yes | Frames go straight to a composited surface, often from hardware decode. Cheap per frame on the main thread and continuous for the whole duration. |
| Inlining an SVG instead of using `<img>` | yes | yes | yes | no | The graphic becomes DOM: every node is styled and laid out. Sixty inline icons is sixty subtrees through every style pass (Style Calculation). |
| `object-fit` change on a loaded image | yes | no | yes | maybe | The box is unchanged; only how the resource is fitted into it changes, so the geometry pass is skipped. |
| Append a paragraph inside a normal block flow | yes | yes | yes | yes | Style is resolved for the new nodes; layout runs for the containing block and anything after it in flow. Local and cheap. |
| Append a row to a table with auto layout | yes | yes | yes | yes | Column widths depend on all cells, so a new row can relayout every row already on screen. table-layout: fixed makes this local instead (Layout Thrashing). |
| Append content into a reserved-size skeleton | yes | maybe | yes | yes | If the reserved box already has the final dimensions, layout is confined inside it and nothing after it moves — this is the whole point of reserving space. |
| Append an image with width and height attributes | yes | no | maybe | yes | The box is sized from the aspect ratio before any bytes arrive, so decode and paint happen later without a second layout (Responsive Images). |
| Append an image without dimensions | yes | yes | yes | yes | Zero-height until decoded, then it pushes everything below it down. The classic source of visual instability (Visual Stability). |
| Append a stylesheet link mid-document | yes | maybe | yes | maybe | Rendering is blocked until it parses, then every element it matches is restyled — potentially the entire document already on screen (Render-Blocking Resources). |
| Append inside a subtree with `content-visibility: auto` | maybe | maybe | no | no | Offscreen subtrees can skip layout and paint until they approach the viewport, which bounds the per-chunk cost of a long document (content-visibility). |
| `el.textContent = "Saved"` | no | yes | yes | yes | No 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` | yes | no | yes | yes | The 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` | yes | yes | yes | yes | Identical 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)"` | yes | no | maybe | yes | Inline 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"` | yes | no | maybe | yes | Same 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 loop | yes | yes | yes | yes | The 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)` | yes | yes | yes | yes | A 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` | yes | yes | yes | yes | The 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")` | yes | maybe | yes | yes | Invalidation 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 write | yes | yes | no | no | The 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` subtree | yes | maybe | maybe | yes | Containment 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")` | maybe | no | no | no | Costs 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). |
| Toggle a class on one element | yes | maybe | maybe | yes | One invalidation. Layout only if the winning declarations change geometry; the engine knows which properties those are and skips layout when none did. |
| Toggle a class on `<html>` (theme switch) | yes | maybe | yes | yes | The invalidation set is the whole document. Cheap in code, the single most expensive style operation most applications perform (Style Invalidation). |
| Write `el.style.transform` | yes | no | no | yes | Style recalculation for one element, then the compositor handles it. This is the mechanism behind the advice, not a magic property (Cheap and Expensive Animation). |
| Write `el.style.width` | yes | yes | yes | yes | Geometry changed, so the browser must lay out this box and anything whose size or position depends on it. |
| Insert a `<style>` element | yes | maybe | maybe | yes | Parse plus re-index plus a document-wide invalidation, because the new rules could match anything. |
| Set `sheet.disabled = true` | yes | maybe | maybe | yes | No re-parsing — the index already exists — but every element that matched a rule in it must be recomputed. |
| Call `getComputedStyle(el).width` | yes | yes | no | no | Not a change at all: a *read* that forces pending style and layout to be flushed so the browser can give you a real number (Layout Thrashing). |
| `getComputedStyle(el).color` | yes | no | no | no | Flushes pending style only. color is fully resolved at computed-value time, so no geometry is needed. |
| `getComputedStyle(el).width` | yes | yes | no | no | A used value. The browser must finish layout before it can answer with a number. |
| `el.getBoundingClientRect()` | yes | yes | no | no | Always a used-value read. Cheap once per frame, ruinous once per list item (Layout Thrashing). |
| Change `color` on `:root` | yes | no | yes | yes | Inherited, so every descendant is invalidated — but no geometry changed, so layout is skipped. |
| Change `font-size` on `:root` | yes | yes | yes | yes | Inherited *and* geometric. Every descendant restyles and the whole document relayouts. The most expensive one-line change in CSS. |
| Toggle `display: none` on a subtree | yes | yes | yes | yes | Boxes are destroyed and everything after it reflows. Also removes the subtree from the accessibility tree. |
| Toggle `visibility: hidden` on a subtree | yes | no | yes | yes | Inherited, and the box is preserved, so surrounding geometry does not move. Still removed from the accessibility tree. |
| Set a colour token on `:root` (theme toggle) | yes | no | yes | yes | Every element inherits it, so the invalidation set is the document — but nothing geometric changed, so layout is skipped. Once per toggle is fine. |
| Set a spacing token on `:root` | yes | yes | yes | yes | Inherited *and* consumed by geometric properties. The whole document restyles and relayouts (Style Invalidation). |
| Set a token on one component root | yes | maybe | maybe | yes | Invalidation is bounded by the subtree. Layout only if the token feeds a geometric property inside it. |
| Set a token on `:root` from `pointermove` | yes | maybe | yes | yes | The same document-wide work, per input event. This is the common way custom properties become a performance bug (The Frame Budget). |
| Transition a registered `<color>` property | yes | no | yes | yes | Interpolated per frame, and each frame restyles whatever reads it. Bounded if the property is scoped and inherits: false. |
| Transition a registered `<length>` used for `width` | yes | yes | yes | yes | Layout every frame for the duration. Animate a compositable property instead where the visual result allows (Cheap and Expensive Animation). |
| `getComputedStyle(el).getPropertyValue('--x')` | yes | no | no | no | A read that flushes pending style. Custom properties are resolved at computed-value time, so no layout is required — but it is still a synchronisation point. |
| Toggle a class used only as a key selector, no geometry in the rule | yes | no | yes | yes | One element recalculated; the rule changes colour only, so layout is skipped entirely. |
| Toggle a class on an ancestor that many descendant rules mention | yes | maybe | maybe | yes | The recalculation covers the subtree. Whether layout follows depends on whether any winning declaration is geometric — often it is not, and the cost is pure style. |
| Set a custom property on `:root` consumed by hundreds of elements | yes | maybe | maybe | yes | Substitution runs per consumer. If the variable feeds a length used in sizing, layout follows for all of them. |
| Insert one row into a list styled with `:nth-child` | yes | yes | yes | yes | Sibling invalidation for everything after it, plus real layout for the new box — the two costs compound as the list grows. |
| 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). |
| `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. |
| `width`, `padding`, `border-width`, `margin` | yes | yes | yes | yes | Geometry changes, so every box whose position depends on this one must be recomputed, then repainted in its new place. |
| `box-sizing` | yes | yes | yes | yes | It changes what width resolves to, so it is a geometry change wearing a different name. |
| `border-color` | yes | no | yes | yes | Same box, different pixels. Nothing moves, so layout is skipped entirely. |
| `outline`, `box-shadow` | yes | no | yes | yes | Painted outside the border box and excluded from geometry by design — this is exactly why focus rings do not reflow the page. |
| `transform: scale()` | yes | no | maybe | yes | The layout box is untouched; the compositor transforms already-painted content. Repaint only if the element must be re-rasterised at the new scale for sharpness. |
| `aspect-ratio` on an image with no dimensions | yes | yes | yes | yes | It costs layout once, at parse time, and saves the far worse layout that would have happened when the image decoded (Visual Stability). |
| `color` on a text node | yes | no | yes | yes | Geometry is unchanged, but the glyphs must be re-rasterised in the new colour over the damaged text region. |
| `background-color` on a card | yes | no | yes | yes | One fill command over the card's rectangle — about as cheap as a repaint gets, and the cost is the area. |
| `box-shadow` blur radius | yes | no | yes | yes | A convolution over a region larger than the element. Cost scales with blur radius and device pixel ratio, not with element size alone. |
| `border-radius` on a scrolling container | yes | no | yes | maybe | A non-rectangular clip applied to everything inside; it can force the contents onto a separate surface so the clip can be applied at composite time. |
| `width` on a flex item | yes | yes | yes | yes | Geometry changes for the item and typically its siblings, so paint follows layout across the whole flex line (Layout Thrashing). |
| `transform: translate` on a promoted layer | yes | no | no | yes | The bitmap already exists; the compositor draws it at a different offset. Only true while the element genuinely has its own layer (Cheap and Expensive Animation). |
| `opacity` on non-promoted content | yes | no | maybe | yes | Without a layer this is a blend performed during raster, and it forbids skipping whatever is underneath. |
| `visibility: hidden` | yes | no | yes | yes | The box keeps its geometry, so no layout — but the region it occupied must be repainted with whatever is behind it. |
| `filter: blur()` on a hovered image | yes | no | yes | maybe | Filters often promote the element, which moves the cost from repaint-per-frame to memory-plus-one-raster — a trade, not a win. |
| `transform` — CSS animation, promoted element | no | no | no | yes | Delegated. The compositor interpolates and redraws the existing texture. The main thread is not involved after the initial commit. |
| `transform` — CSS animation, not promoted | yes | no | maybe | yes | No layer to move, so the transform is applied where the content is drawn. No layout, but the main thread is in the loop every frame. |
| `transform` — written per frame from JavaScript | yes | no | maybe | yes | The interpolation is your callback on the main thread. Same property, entirely different scheduling. |
| `opacity` — CSS animation, promoted element | no | no | no | yes | Delegated: an alpha applied to an existing texture at blend time. |
| `opacity` — non-promoted content | yes | no | maybe | yes | A blend at raster time, which also forbids skipping the content underneath. Cost scales with the area, not the element count. |
| `left` / `top` on a positioned element | yes | yes | yes | yes | Position is geometry. Layout runs for the element and anything whose position depends on it, every frame. |
| `width` / `height` | yes | yes | yes | yes | The most expensive common animation: it can reflow siblings, ancestors and text line boxes on every frame. |
| `background-color` | yes | no | yes | yes | No geometry change, but the fill is re-executed over the element's area every frame. |
| `filter: blur()` | yes | no | maybe | yes | Often promoted and sometimes delegated, but the convolution itself still runs per frame — delegation moves the thread, not the arithmetic. |
| No promotion; one card animates transform | yes | no | maybe | yes | The engine promotes the animating card for the animation's duration and demotes it afterwards. One temporary layer, no permanent cost. |
| `will-change: transform` on the animating card only, scoped to the interaction | yes | no | no | yes | One layer for a fraction of a second, and no blank first frame. This is the version the advice is actually about. |
| `will-change: transform` in the card's base class, 200 instances | yes | no | no | yes | Two hundred permanent bitmaps. The animation is delegated and everything else — scroll, memory, compositing per frame — is worse. |
| `will-change: transform` on the scroll container instead | yes | no | maybe | yes | One enormous layer covering the whole list, tiled. Cheaper than 200 layers, still a large allocation, and it does not help the per-card animation at all. |
| Containment on each card, no promotion | yes | no | yes | yes | Invalidation is isolated without allocating a surface. Paint still runs, but only for the card that changed (CSS Containment). |
| `content-visibility: auto` on off-screen sections | maybe | no | no | yes | Off-screen content is skipped entirely — no style, no layout, no paint until it approaches the viewport (content-visibility). |
| Typing in an uncontrolled input | yes | maybe | yes | yes | The browser updates the value and repaints the text. Layout only if the field grows — an auto-sizing textarea, for instance. No application code runs. |
| Controlled input, state scoped to the field | yes | maybe | yes | yes | Same browser work plus a small render. The added cost is JavaScript time inside the interaction, not extra pipeline stages. |
| Controlled input, state at the top of a large form | yes | maybe | yes | yes | Still the same pipeline stages, but the render diffs the entire form per character. The symptom is a delayed frame, not extra layout. |
| Controlled state driving a filtered list | yes | yes | yes | yes | Now the DOM genuinely changes: rows are added and removed, so layout and paint are unavoidable. This is where debouncing the derived work pays (List Virtualization). |
| Validity class toggling on `:user-invalid` | yes | maybe | yes | no | A colour or border change repaints; layout only if the error message changes the element's box. |
| Error message appearing below the field | yes | yes | yes | yes | Inserting a node reflows what follows it. Reserving the space up front avoids the content shift (Visual Stability). |
| Adding `container-type: inline-size` | yes | yes | maybe | no | It applies layout, style and size containment in the inline axis. The element stops sizing to its content inline and establishes an independent formatting context — a genuine geometry change, applied the moment the declaration lands. |
| Adding `container-type: size` | yes | yes | maybe | no | Containment in both axes, so the element no longer sizes to content at all. An automatic height collapses to zero. This is the "my component vanished" case. |
| A container resizing across a `@container` boundary | yes | yes | yes | maybe | The query re-evaluates during layout, the subtree restyles, and it is laid out again. Containment is what guarantees the second pass cannot change the container and start a third. |
| The `ResizeObserver` equivalent | yes | yes | yes | maybe | Same rendering work, plus a main-thread callback per observed element, plus a forced layout read, plus a one-frame lag in which the wrong variant is on screen (Layout Thrashing). |
| Reading a `cqi` unit instead of `vw` | yes | maybe | maybe | no | Resolving against the container rather than the viewport is the same arithmetic against a different reference. It becomes layout-relevant only because the container size is itself a layout output. |
| Re-render producing identical output | no | no | no | no | Reconciliation finds no difference and emits no mutation. The cost is framework work only, and it is usually microseconds. |
| Text content changes, same box size | yes | maybe | yes | yes | Style resolves for the affected node and paint redraws it. Layout is skipped only if the new text does not change the box — with intrinsic sizing anywhere above it, it will (Intrinsic Sizing and the Automatic Minimum). |
| Class toggled that changes colour only | yes | no | yes | yes | A paint-only property. Style must recompute for the subtree the selector affects, which can be far wider than the element you had in mind. |
| Class toggled that changes width | yes | yes | yes | yes | Geometry changed, so layout runs for the containing block and everything it affects. The full pipeline, every time. |
| Row inserted into a list | yes | yes | yes | yes | New nodes need style and boxes, and siblings after it move. Cost scales with what follows the insertion point, not with the row itself. |
| List reordered with stable keys | maybe | yes | maybe | yes | The framework moves existing nodes rather than recreating them. Positions change so layout runs; paint may be reusable if nothing about the nodes changed. |
| Same reorder with index keys | yes | yes | yes | yes | The framework matches by position, so it rewrites the contents of every row instead of moving any. Focus, scroll and per-row state are destroyed as well (Node Identity Across Updates). |
| Animating `transform` on a composited element | no | no | no | yes | Handled by the compositor without re-running style, layout or paint — provided the element already has its own layer and nothing else forces it back (Compositing Layers). |
| Reading `offsetHeight` after a write | yes | yes | maybe | maybe | Forces the browser to compute layout synchronously before the read returns. In a loop this runs once per iteration and is the classic frontend performance bug (Layout Thrashing). |
| Root boundary: whole page becomes a centred spinner | yes | yes | yes | yes | Every element is unmounted and re-created. Style is recomputed for the entire document, layout runs over all of it, and the whole viewport repaints — twice per navigation, since the content comes back the same way. |
| Outlet boundary: one pane becomes a skeleton of the same size | yes | maybe | yes | maybe | Style for the new nodes only. Layout is avoidable if the skeleton reserves the same box and the container does not size to its content; paint is limited to the pane's area. |
| Retain the old view, add an inline determinate progress bar | yes | no | yes | maybe | Nothing unmounts, so no geometry changes. If the bar is animated with transform on its own layer, the per-frame update is compositor work rather than paint (Cheap and Expensive Animation). |
| Fade the retained view to reduced opacity while pending | yes | no | no | yes | Opacity on an already-promoted layer is a compositor-only change: no geometry, no repaint of the contents. The cheapest honest pending signal available. |
| Skeleton replaced by content of a different height | yes | yes | yes | yes | The shift the user actually feels. Everything after the boundary in flow moves, which is a layout pass over the rest of the document and the reason skeleton dimensions matter (Visual Stability). |
| Append 20 rows to the end of a list | yes | yes | yes | yes | New boxes must be styled, positioned and painted. Existing rows above are usually untouched, which is what makes appending the cheap direction. |
| Insert 3 rows at the top of a long list | yes | yes | yes | yes | Everything below moves, so layout runs over the whole list and the scroll position shifts under the reader — the reason to put new items behind a "3 new items" control (Visual Stability). |
| Replace the whole list (new filter) | yes | yes | yes | yes | The full cost of the list, plus discarding the old nodes. Keyed reconciliation cannot help when nothing is reused (Reconciliation and Keys). |
| Append with index-based keys | yes | yes | yes | yes | Every row is now associated with different data, so the framework updates all of them instead of adding twenty. The change is small and the work is proportional to the whole list. |
| Toggle a row's selected class | yes | maybe | yes | maybe | Layout only if the rule changes geometry — a border or padding does, a background colour does not. Choose the property with that in mind (The Cost of a Change). |
| Scroll a virtualised window by one page | yes | maybe | yes | yes | Rows are recycled rather than added, so node count is constant; layout depends on whether row heights are fixed or measured (List Virtualization). |
| Show a spinner in reserved space at the list end | yes | no | yes | yes | The space was already allocated, so nothing above it moves — which is the entire reason to reserve it. |
| Toggle a boolean on one row (star, read, pinned) | yes | no | yes | yes | A class or attribute change on one element. Geometry is unchanged, so the confirmation frame is usually a no-op if the server agrees. |
| Optimistically insert a row with a temporary id | yes | yes | yes | yes | The list grows, so everything below reflows. Then the real id arrives and the row is re-keyed — a second insert and remove unless identity is preserved. |
| Reorder a list by drag | yes | yes | maybe | maybe | Reordering DOM nodes relayouts the container. A transform-based reorder can stay on the compositor, but only if positions are not also being written (Cheap and Expensive Animation). |
| Optimistic edit to a text field the server may normalise | yes | maybe | yes | yes | Layout depends on whether the normalised text is a different length — which you cannot know, which is exactly the problem. |
| Rollback of any of the above | yes | maybe | yes | yes | A third render. Cheap in browser terms and expensive in user terms: it is the frame where the interface contradicts itself. |
| Prediction that recomputes a derived total or count | yes | maybe | yes | yes | The derived value renders elsewhere on the page, so one optimistic write can invalidate regions the user is not looking at (Derived State). |
| Patch one field on one row (text content) | yes | maybe | yes | yes | Style recalculation is scoped to the element; layout is only needed if the new text changes the box's intrinsic size, which for a fixed-width numeric column it usually does not. |
| Replace the whole collection, keys preserved | yes | maybe | maybe | yes | With stable keys the framework patches in place, so the cost approximates the sum of the rows that actually changed rather than the size of the list (Reconciliation and Keys). |
| Replace the whole collection, identity lost | yes | yes | yes | yes | Every node is destroyed and recreated, so every box must be laid out and painted again — and focus and text selection inside the list are lost with the nodes. |
| Reorder rows after a resync | yes | yes | maybe | yes | Geometry changes for everything after the first moved row; paint may be avoidable if the rows themselves are unchanged and the engine can reuse their painted output. |
| Remove rows deleted during the gap | yes | yes | yes | yes | Everything below shifts up, which is a layout the user perceives as content jumping — worth animating or batching so it happens once (Visual Stability). |
| Apply 200 replayed events one at a time | yes | yes | yes | yes | The stages are not the problem; running them up to 200 times is. Buffering into one commit per frame collapses this to roughly the cost of the final state (Yielding and Scheduling). |
| Update a status label outside the list | yes | no | yes | yes | Reserve the label's space so that "Reconnecting" and "Live" occupy the same box; otherwise an honest status indicator becomes a source of layout shift. |
| Toggle `aria-disabled` and a description on an existing button | yes | no | maybe | no | An attribute change re-runs style for that element; layout is untouched because geometry does not change. Paint only if the disabled state alters colours. |
| Toggle `hidden` / `display: none` on a control | yes | yes | yes | yes | The box leaves or enters flow, so siblings move. This is the version users feel as a jump when capabilities arrive late (Visual Stability). |
| Replace a button element with a static text label | yes | yes | yes | yes | New nodes, new intrinsic sizes, and focus is lost if the removed node held it (Node Identity Across Updates). |
| Toggle `visibility: hidden` on a control | yes | no | yes | maybe | The box keeps its space, so nothing shifts — but it stays in the layout tree and, critically, is removed from the accessibility tree too, so it is not a way to keep it announced. |
| Render a whole permission-dependent region after a separate capabilities fetch | yes | yes | yes | yes | A full insertion into flow, late, after the user has begun reading. Reserve the space or attach capabilities to the original response instead. |
| Image loads with no reserved dimensions | yes | yes | yes | yes | The box goes from zero height to its intrinsic height, so every subsequent box in flow is repositioned and repainted. |
| Image loads with `width`/`height` or `aspect-ratio` | no | no | yes | yes | The box was already the right size; only its content is new. This is the fix, stated as a cost table. |
| Web font swaps in with different metrics | yes | yes | yes | yes | Line box heights and line breaking depend on font metrics, so text reflows wherever the family is used. |
| Web font swaps in with matched metrics | yes | maybe | yes | yes | Overrides make the fallback occupy the same space; layout may still run, but geometry does not change, so nothing visibly moves. |
| Banner inserted at the top of the flow | yes | yes | yes | yes | Everything below it moves down by the banner height. Scroll anchoring may compensate for the scroll position but not for a user mid-tap. |
| Same banner as a fixed overlay | yes | maybe | yes | yes | Out of flow, so nothing after it moves; it covers content instead, which is a different trade rather than no trade. |
| Accordion expands on click | yes | yes | yes | maybe | A real geometry change that the stability metric excludes because it followed input. Users still experience the jump. |
| Animating `transform` on a late element | no | no | no | yes | Composited: nothing in flow is disturbed. That is why it is the tool for motion that must not move neighbours. |
| Hydration matches: listeners attached, no DOM change | no | no | no | no | The intended path. The DOM is untouched, so none of the rendering stages run again — the cost is entirely the main-thread walk that produced the match. |
| A text node corrected in place | no | maybe | yes | yes | The text must be repainted. Layout runs again only if the new string changes the size of its box, which is why reserving width turns a maybe into a no. |
| An attribute corrected (`aria-expanded`, `data-state`) | maybe | maybe | maybe | maybe | Depends entirely on whether any selector matches on that attribute. If none does, this is a pure accessibility-tree change with no visual cost at all — and no visual signal either, which is why this class hides so well. |
| A class corrected on a container | yes | maybe | yes | yes | Style must be recomputed for the element and anything inheriting from it. Whether layout follows depends on which properties the class changes (The Cost of a Change). |
| A subtree discarded and rebuilt | yes | yes | yes | yes | Every node is constructed again, styled again, laid out again and painted again — over a region the browser had already finished. This is the visible flash users report. |
| Fallback to a full client render of the root | yes | yes | yes | yes | The entire document body is replaced. The server render is now pure overhead: it cost a per-request render, delayed the first byte, and its output was thrown away (Client-Side Rendering). |
| A design token value changes (a spacing or colour custom property) | yes | maybe | yes | yes | The DOM is byte-identical, so every assertion still passes. Whether layout runs depends on whether the token feeds a geometric property; if it does, everything downstream of it moves (Custom Properties). |
| A stylesheet rule's specificity changes and a different rule wins | yes | maybe | yes | yes | Structure is unchanged and computed style is not what tests assert on. This is the classic regression that only pixels catch (Specificity). |
| Translated text is much longer than the source | no | yes | yes | yes | Same DOM shape, same roles, same test queries — and a button label that now wraps out of its container (Intrinsic Sizing and the Automatic Minimum). |
| A container gains `overflow: hidden` | yes | maybe | yes | maybe | The clipped content is still in the DOM, so a query finds it and a visibility assertion in a simulated document may still pass. The user cannot read it. |
| A focus ring is removed by a reset | yes | no | yes | no | Nothing about focus behaviour changed, so focus assertions pass. The indicator that told a keyboard user where they are has gone (Keyboard Operability). |
| A stacking context changes and a menu renders behind content | yes | no | yes | yes | The menu is present, named and clickable by the test runner. To a person it is underneath something (Positioning and Stacking Contexts). |
| A handler is removed so the button does nothing | no | no | no | no | The mirror image: nothing reaches the pixels at all, so the visual test passes happily while the feature is dead. This is why the level is a complement, never a replacement. |
| Animating `transform` on a promoted element | no | no | no | yes | The compositor can move an already-rasterised layer without the main thread. If the recording shows layout here, the element is not actually on its own layer (Compositing Layers). |
| Animating `left` or `top` | yes | yes | yes | yes | Position participates in flow, so geometry has to be recomputed every frame and the result repainted (Normal Flow, Overflow and Margin Collapsing). |
| Changing `opacity` on an element that is not promoted | yes | no | maybe | yes | Opacity never affects geometry. Whether paint is needed depends on whether the engine could isolate the element into a layer. |
| Toggling a class that changes `background-color` | yes | no | yes | yes | Colour is a paint-only property, but the repainted area may be much larger than the element if effects overlap it (Paint Commands). |
| Reading `offsetHeight` after a style write | yes | yes | maybe | maybe | This is the forced synchronous layout devtools flags. In a loop it produces one layout per iteration (Layout Thrashing). |
| Inserting rows into a long list | yes | yes | yes | yes | Cost scales with how much of the tree is invalidated, not with the number of rows inserted — containment can bound it (CSS Containment). |
| Changing a custom property used across the page | yes | maybe | maybe | maybe | Everything downstream of the property must be recomputed; whether layout follows depends entirely on which properties consume it (Custom Properties). |
| Adding a large blurred shadow on hover | yes | no | yes | yes | Geometry is unchanged, but rasterising the effect is expensive and the invalidated region extends beyond the element (Cheap and Expensive Animation). |
| Toggle `data-theme` on the root, colour tokens only | yes | no | yes | yes | Custom properties inherit, so computed style is invalidated for every element that inherits them; geometry is untouched, so the browser repaints without re-measuring (Style Invalidation). |
| Change a spacing or type-scale token | yes | yes | yes | yes | The token feeds a geometric property, so boxes actually change size and the layout pass has to run over everything affected (Layout Thrashing). |
| Change a component token on one element | yes | maybe | maybe | maybe | Scoped to that element's subtree — but whether it costs layout depends on which property the token feeds, and whether it costs paint depends on whether the element is visible at all. |
| Change a motion-duration token | yes | no | no | maybe | Nothing repaints from the change itself; it alters the duration of animations that start afterwards, and only touches the compositor if one of those animations is compositor-driven (Cheap and Expensive Animation). |
| Swap a whole stylesheet instead of re-pointing tokens | yes | maybe | yes | yes | The new sheet must be fetched and parsed before it applies, so there is a window showing the old theme — the flash that re-pointing custom properties avoids entirely (Render-Blocking Resources). |
| Subtree swapped for the other branch | yes | yes | yes | yes | New elements need computed styles, their geometry is unknown, and everything after them in normal flow may move. This is the most expensive and most visible form of a flip. |
| Flag toggles a class that changes only `color` | yes | no | yes | no | A paint-only property: the box does not change, so geometry is untouched and only the affected paint area is redrawn (Cheap and Expensive Animation). |
| Flag toggles `display: none` to `block` | yes | yes | yes | maybe | The element re-enters flow, so its own geometry and its siblings' positions are computed for the first time. Whether a new compositing layer is involved depends on what the revealed content contains. |
| Flag gates a lazily imported component | maybe | maybe | maybe | maybe | Nothing costs anything until the chunk arrives; then the full mount happens at whatever moment the network delivers it, which is later and less predictable than a flip of already-loaded code (Lazy Loading). |
| Flag read, but both branches render identical DOM | no | no | no | no | The framework may still re-render. A re-render that produces the same DOM costs script time and nothing downstream — which is why "it re-rendered" and "it was expensive" are different claims (What a Component Costs to Render). |
| Content hidden until flags resolve (anti-flicker) | yes | yes | yes | no | The shift has not been removed, it has been moved to before the first paint — and the page is now blank for as long as the flag service takes. Reserving space is usually the better trade (Visual Stability). |
caveat A change that skips layout on a page with one compositor layer can still force it on a page with a hundred. Confirm in the Performance panel rather than from the row.