Performance

Know what CSS actually costs at runtime, and which choices are worth making.

25of 26
≈ 10Min read
9Parts

The pipeline, and where cost lives

Changing width costs layout, paint and composite. Changing background-color costs paint and composite. Changing transform or opacity can cost composite alone. That is the whole basis of "animate transform and opacity".

  • Style — match selectors, resolve the cascade. Fast, and almost never your problem.
  • Layout — work out every box's size and position. Expensive, and it cascades: one change can reflow a whole subtree.
  • Paint — fill in pixels. Moderate, and proportional to area.
  • Composite — assemble the layers. Cheap, and can run off the main thread.

Selector performance is a myth you can ignore

Matching selectors is measured in microseconds on real pages, and the browser matches right to left, so even a long descendant selector is cheap. Write selectors for maintainability, not speed.

The one exception worth knowing: very large DOMs with thousands of elements plus expensive :has() conditions can show up in a profile. Measure before you act on that.

What genuinely costs

ThingCostDo instead
Animating width, height, top, marginLayout every frameAnimate transform
Animating box-shadow or filter: blur()Repaint a large areaCross-fade a pre-rendered pseudo-element
backdrop-filter on a moving elementRead-back plus blur per frameUse it on something static, once
A very large will-change listMemory, one layer eachApply it just before, remove after
A huge unused stylesheetDownload, parse, style recalcSplit by route, remove dead rules
A webfont with font-display: blockInvisible text for up to 3sswap or optional

Loading

  • A stylesheet in the head blocks rendering. That is correct — but only ship what the page needs.
  • Inline the small amount of CSS needed for the first screen, and load the rest asynchronously, if the page is large enough for it to matter.
  • @import inside a stylesheet is serial: the browser cannot even discover the second file until the first has parsed. Bundle instead.
  • Preload the one or two fonts used above the fold; everything else can wait.
load a non-critical stylesheet without blocking
<link rel="stylesheet" href="/print.css" media="print">
<link rel="preload" as="style" href="/rest.css" onload="this.rel='stylesheet'">

Containment

contain: layout promises that nothing inside affects the outside, so a change inside cannot reflow the page. content-visibility: auto skips rendering entirely for off-screen elements — on a long list it is the single biggest win available, and contain-intrinsic-size stops the scrollbar jumping while it guesses.

tell the browser where changes stop
.card {
  contain: layout paint;
}

/* a long list: skip work for rows that are off screen */
.row {
  content-visibility: auto;
  contain-intrinsic-size: auto 72px;   /* the guess that keeps the scrollbar honest */
}
It is a promise, not a hint

If a contained element does need to affect the outside — a dropdown that escapes its card — containment will clip or mis-size it. Apply it to things that are genuinely self-contained.

will-change

will-change promotes the element to its own compositor layer ahead of time, which avoids a hitch on the first frame. Every layer costs memory, and a page with hundreds of them is slower, not faster. Add it just before the animation and take it off afterwards.

use it narrowly
.panel.is-opening {
  will-change: transform, opacity;
}

/* and remove it when the animation ends */

Layout thrashing

Reading a geometric property — offsetHeight, getBoundingClientRect, scrollTop, getComputedStyle — after writing a style forces the browser to lay out immediately so it can answer. Alternating them in a loop makes it do that once per item.

the pattern that kills frame rate
// BAD: read, write, read, write — forces a synchronous layout each time
for (const el of items) {
  el.style.height = el.offsetHeight + 10 + "px";
}

// GOOD: read everything, then write everything
const heights = items.map((el) => el.offsetHeight);
items.forEach((el, i) => { el.style.height = heights[i] + 10 + "px"; });

Measuring

Do not optimise from a list of rules, including this one. Record a profile, find the long bar, fix that.

  • Performance panel — record an interaction. Purple is style and layout, green is paint, yellow is script. Long purple bars mean layout.
  • Rendering panel — "Paint flashing" shows what repaints; "Layout Shift Regions" shows what jumps.
  • Coverage panel — how much of each stylesheet the page actually used.
  • Lighthouse — Cumulative Layout Shift and Largest Contentful Paint are the two that CSS most often causes.

The three that matter most in practice

  • Give images and videos width and height. It is the main cause of layout shift and it costs one attribute.
  • Animate only transform and opacity. Everything else in an animation is a per-frame layout or paint.
  • Ship less CSS. The fastest stylesheet is the one that is not downloaded, parsed and matched.

Practice