CSS Performance: Layout Thrash, Paint Costs, and the Properties That Are Cheap Now
Most CSS performance advice is either true-but-obsolete or true-but-irrelevant. This page is about the four costs you can actually measure and the two that people still optimise for free.
The pipeline, and which step your change touches
Style → layout → paint → composite. A change that invalidates an early step redoes everything after it:
| You change | Recomputes |
|---|---|
color, background, box-shadow | paint (+composite) |
width, padding, font-size, gap | layout → paint → composite |
transform, opacity | composite only |
filter during animation | paint on every frame — the trap |
This is why "animate transform, not top" survived a decade of browser changes. It is also why width transitions on a card in a flex row are measurably worse than transform: scaleX() on the same card: one reflows its siblings, the other does not.
Layout thrash is a JS/CSS bug, and it is the expensive one
// quadratic: each read forces a layout of everything written before it
for (const el of items) {
el.style.width = computeWidth(el); // write
if (el.offsetWidth > limit) shrink(el); // read -> forced sync layout
}
Fix by separating phases — read all, then write all:
const widths = items.map(el => el.offsetWidth); // reads first
items.forEach((el, i) => { el.style.width = … }); // then writes
The Performance panel names this exactly: a Layout bar with a red triangle and "Forced reflow" in the details. It is rarely the CSS's fault and always shows up as CSS in the flame chart, which is why it gets blamed on the stylesheet. Reading offsetWidth, getBoundingClientRect(), scrollTop, or getComputedStyle() after a style write is the trigger; requestAnimationFrame batching is the standard fix.
Containment: the biggest real win for long lists
.chat-row { contain: layout style; } /* its internals cannot affect outside layout */
.virtual-item { contain: strict; } /* size fixed; skips all internal work */
.card { content-visibility: auto; } /* skip layout+paint while off-screen */
content-visibility: auto is the one with the largest measured effect on a long scrolling page: off-screen subtrees are not laid out or painted until they approach the viewport. Two documented caveats:
- Scroll-height stability. An unrendered subtree contributes its containment block size, not its real size, so the scrollbar changes as you scroll. Set a
min-block-sizeon the container so the estimate is close. - Find-in-page and anchor jumps can miss hidden content until it is rendered.
contain-intrinsic-size is the companion that pins the estimate, and it is what makes content-visibility: auto usable in practice.
Paint costs that are still real
box-shadowwith a large blur — the blur radius is the cost, and it is paid per element. Twenty cards withblur(24px)shadows is a genuine cost on a phone; the common "cheaper" alternative (a pseudo-element with a filter) is not cheaper.backdrop-filter— blurs everything behind it, which means reading a framebuffer region every frame the content behind moves. One modal: fine. A sticky header over a scrolling page: visible on mid-range hardware.border-radiuson a huge area, or on an animating element — antialiasing a curved edge that is repainting each frame is a per-frame cost.- Gradients and
filter: blur()on animating elements — repainted per frame. Bake the gradient into an image, or animate the transform of an already-painted layer. - Opacity vs. visibility:
opacity: 0still paints (into a composited layer, usually cheap);visibility: hiddenstill lays out;display: noneskips everything but invalidates layout when flipped.content-visibilityis the modern "skip the work but keep the scroll position" answer.
Will-change: the anti-pattern to delete
/* wrong: promotes every card to its own compositing layer, forever */
.card { will-change: transform; }
Layers cost memory and compositing time. will-change is a hint to apply immediately before an animation (or via a class set by JS/:hover) and to remove after. If a stylesheet contains a global will-change, deleting it is often a performance improvement — check before adding one.
The two free optimisations people still argue about
Selector rightmost-first matching. Browsers match from the right, so #nav .item a span is not a real cost — the span filter rejects almost everything before anything else is consulted. Deep descendant chains are a maintainability problem (specificity), not a speed one.
CSS file size. A 200 KB stylesheet is a network and parse cost, paid once, not a runtime cost. Ship less CSS with purge/unused rules and layers — but the reason is transfer on a cold first visit, and it is not why scrolling feels bad.
How to measure what you cannot see
- Performance panel, record with 4-6× CPU throttling. A GB10-class desktop will show you nothing that a mid-range phone will hit.
- Look at the ratio of Recalculate Style / Layout / Paint bars. Recalculate-dominant → too many or too-wide selectors and JS writing styles. Layout-dominant → thrash or un-contained subtrees. Paint-dominant → shadows, filters, radius.
- Coverage tab (Rendering → Coverage): the unused-CSS percentage on the page you are actually shipping, which is the only defensible number for "is this stylesheet too big".
- Rendering → Paint flashing: green tiles are repaints. A page that flashes green everywhere on hover has an invalidation problem, and you can see which element.
Details on reading these panels are in DevTools debugging. Related: patterns and gotchas for the containment behaviours that surprise people, and minify safety before trusting a build step's output.