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
| Thing | Cost | Do instead |
|---|---|---|
Animating width, height, top, margin | Layout every frame | Animate transform |
Animating box-shadow or filter: blur() | Repaint a large area | Cross-fade a pre-rendered pseudo-element |
backdrop-filter on a moving element | Read-back plus blur per frame | Use it on something static, once |
A very large will-change list | Memory, one layer each | Apply it just before, remove after |
| A huge unused stylesheet | Download, parse, style recalc | Split by route, remove dead rules |
A webfont with font-display: block | Invisible text for up to 3s | swap 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.
@importinside 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.
<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.
.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 */
}
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.
.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.
// 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
widthandheight. It is the main cause of layout shift and it costs one attribute. - Animate only
transformandopacity. 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.