CSSTidy CSS Formatter

CSS Performance: Layout Thrash, Paint Costs, and the Properties That Are Cheap Now

CSSTidy — CSS Formatter & Minifier Guides · Updated 2026-10-12 · All guides

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 changeRecomputes
color, background, box-shadowpaint (+composite)
width, padding, font-size, gaplayout → paint → composite
transform, opacitycomposite only
filter during animationpaint 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:

  1. 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-size on the container so the estimate is close.
  2. 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-shadow with a large blur — the blur radius is the cost, and it is paid per element. Twenty cards with blur(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-radius on 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: 0 still paints (into a composited layer, usually cheap); visibility: hidden still lays out; display: none skips everything but invalidates layout when flipped. content-visibility is 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

  1. Performance panel, record with 4-6× CPU throttling. A GB10-class desktop will show you nothing that a mid-range phone will hit.
  2. 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.
  3. 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".
  4. 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.

Developer Sponsor / Partner
Copied to clipboard!