CSS That Renders Fast
CSS performance is not about writing shorter selectors. Selector matching has been fast for a decade. What costs real time is the amount of work a change forces the browser to redo, and how much of the page that work touches.
Understanding the pipeline is the whole lesson. Everything else follows from it.
The pipeline, stage by stage
When something changes, the browser runs some suffix of this sequence:
- Style - work out which declarations apply to which elements and compute the final value of every property.
- Layout - work out the size and position of every box. Also called reflow.
- Paint - fill in pixels: text, colours, borders, shadows, images. Into layers, not directly to the screen.
- Composite - assemble the painted layers into the final frame.
The further back a change starts, the cheaper it is. Change color and you skip layout: style, paint, composite. Change transform and you skip layout and paint: the layer already exists, so only compositing runs - and compositing can happen on a separate thread, which is why transforms stay smooth while JavaScript is blocking.
Change width and all four stages run, for the element and for everything whose position depends on it. That is the expensive case, and in a deep document "everything that depends on it" can be the rest of the page.
/* composite only - cheapest */
.a { transform: translateX(10px); }
.b { opacity: 0.5; }
/* paint and composite */
.c { background: #eee; }
.d { box-shadow: 0 2px 8px rgb(0 0 0 / 0.2); }
/* style, layout, paint and composite - most expensive */
.e { width: 300px; }
.f { padding: 2rem; }
.g { font-size: 1.25rem; }
This does not mean never change a width. It means know that you did, and do not do it sixty times a second.
Layout thrashing: the bug this knowledge prevents
The commonest real performance bug in a web application is not slow CSS. It is JavaScript interleaving reads and writes of layout.
// Bad: forces a synchronous layout on every iteration.
for (const card of cards) {
card.style.height = card.offsetHeight + 10 + 'px';
}
Open this example in the editor and change it. Nothing you type is saved.
Reading offsetHeight requires an up-to-date layout. Writing style.height invalidates it. Alternating the two means the browser must recompute layout on every pass through the loop, synchronously, blocking everything. With 200 cards that is 200 layouts where one would have done.
The fix is to batch: read everything, then write everything.
// Good: one layout for all the reads, one for all the writes.
const heights = cards.map((card) => card.offsetHeight);
cards.forEach((card, i) => {
card.style.height = heights[i] + 10 + 'px';
});
Open this example in the editor and change it. Nothing you type is saved.
The properties that force a synchronous layout when read are worth recognising: offsetTop, offsetLeft, offsetWidth, offsetHeight, scrollTop, scrollWidth, clientWidth, clientHeight, getComputedStyle() and getBoundingClientRect(). Reading any of them after a write is the pattern to look for.
Containment: telling the browser what cannot escape
contain is a promise you make about an element, and the browser spends the promise on doing less work.
.card {
contain: layout paint;
}
contain: layoutpromises that nothing inside affects the layout of anything outside. The browser can then lay out the card's contents without reconsidering the rest of the page.contain: paintpromises that nothing inside paints outside the element's bounds. Descendants are clipped, and an off-screen card need not be painted at all.contain: sizepromises that the element's size does not depend on its contents - which means you must give it a size, or it collapses. This is the one that bites.contain: contentis shorthand forlayout paint style. A good default for a repeated, self-contained component.
The payoff is largest exactly where it matters: a long list of cards, a feed, a table of rows. Without containment, a change inside row 400 may invalidate layout for the whole list. With it, the invalidation stops at the row's boundary.
/* A feed of hundreds of items. */
.feed-item {
contain: content;
/* Skip rendering work entirely while off screen, but keep the
scrollbar honest by telling the browser roughly how big it is. */
content-visibility: auto;
contain-intrinsic-size: auto 8rem;
}
content-visibility: auto is containment taken to its conclusion: an off-screen element is not styled, laid out or painted at all until it approaches the viewport. On a long page this can turn a one-second first render into a fast one. contain-intrinsic-size supplies a placeholder size so the scrollbar does not jump as items render - leave it out and scrolling becomes unpleasant.
A worked measurement
Here is a page that makes the difference visible rather than theoretical.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Measuring layout cost</title>
<style>
body { font: 16px/1.6 system-ui, sans-serif; margin: 0; padding: 2rem; }
.feed { display: grid; gap: 0.5rem; max-width: 40rem; }
.item {
padding: 1rem;
border: 1px solid #e3e5ea;
border-radius: 8px;
background: #fff;
}
/* Toggle this class on the feed to see the difference. */
.contained .item {
contain: content;
content-visibility: auto;
contain-intrinsic-size: auto 5rem;
}
output { display: block; margin-block: 1rem; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<h1>Layout cost, measured</h1>
<button id="thrash">Read and write alternately</button>
<button id="batch">Read all, then write all</button>
<button id="toggle">Toggle containment</button>
<output id="result">Press a button.</output>
<div class="feed" id="feed"></div>
<script>
const feed = document.getElementById('feed');
const result = document.getElementById('result');
for (let i = 0; i < 500; i++) {
const item = document.createElement('div');
item.className = 'item';
item.textContent = 'Item ' + (i + 1) + ' - some text to give it height.';
feed.append(item);
}
const items = [...feed.children];
function time(label, work) {
const start = performance.now();
work();
result.textContent = label + ': ' + (performance.now() - start).toFixed(1) + 'ms';
}
document.getElementById('thrash').addEventListener('click', () => {
time('interleaved', () => {
for (const item of items) {
item.style.minHeight = item.offsetHeight + 1 + 'px';
}
});
});
document.getElementById('batch').addEventListener('click', () => {
time('batched', () => {
const heights = items.map((item) => item.offsetHeight);
items.forEach((item, i) => { item.style.minHeight = heights[i] + 1 + 'px'; });
});
});
document.getElementById('toggle').addEventListener('click', () => {
feed.classList.toggle('contained');
result.textContent = 'containment ' +
(feed.classList.contains('contained') ? 'on' : 'off');
});
</script>
</body>
</html>
Open this example in the editor and change it. Nothing you type is saved.
Press the two buttons in turn. On a typical laptop the interleaved version takes tens of milliseconds and the batched version takes a few - the same work, one or two orders of magnitude apart. Then turn containment on and press them again.
What actually makes a stylesheet slow to load
Rendering cost is one half. The other is getting the CSS to the browser at all, because CSS blocks rendering by design: the browser will not paint until it knows what things look like, to avoid showing you unstyled content.
- Keep the critical stylesheet small. Everything in it delays first paint. One hundred kilobytes of CSS of which the home page uses four is four kilobytes of value and a hundred of delay.
@importinside CSS is the worst case. The browser must download and parse the first file before it discovers the second. Two round trips in series. Use several<link>elements, which fetch in parallel, or bundle at build time.- Media-qualify what is conditional.
<link rel="stylesheet" href="print.css" media="print">is downloaded but does not block rendering. - Watch web fonts.
font-display: swapshows text in a fallback immediately rather than leaving a blank space;size-adjuston the fallback reduces how much the page shifts when the real font arrives. - Reserve space for images.
widthandheightattributes, oraspect-ratio, stop the content below jumping once the image loads. Layout shift is a performance problem people can feel, even when the numbers look fine.
The selectors that are worth thinking about
Selector performance is mostly a non-issue, with two exceptions worth knowing.
The first is descendant selectors on frequently-invalidated subtrees. A browser matches selectors right to left, so .sidebar div p span starts from every span on the page and walks up. That is still fast in isolation, but it is re-evaluated whenever the subtree changes.
The second is the universal selector in a descendant position, as in .wrapper * { ... }, which does genuinely more work than a class.
Neither will be your bottleneck. Measure before you refactor selectors; measure again before you believe the refactor helped.
When it goes wrong
| Symptom | Cause |
|---|---|
| Scrolling janks on a long list | No containment, or shadows and filters repainting per frame |
| An interaction takes 300ms with no network call | Interleaved layout reads and writes in script |
| Animation is smooth in isolation, awful in the page | Animating layout properties while the main thread is busy |
An element with contain: size has collapsed |
Size containment with no explicit size given |
| The scrollbar jumps while scrolling | content-visibility: auto with no contain-intrinsic-size |
| First paint is late on a fast connection | @import chains, or one large blocking stylesheet |
| Content jumps as the page loads | Images with no dimensions, or a web font with no size-adjust |
| DevTools shows long purple bars | Style and layout recalculation - look at what invalidated them |
A check you can run
Open DevTools, go to the Performance panel, start recording, interact with your page, and stop. Then read the flame chart for one frame.
You are looking for three things. Purple is style and layout; a lot of it means something is invalidating geometry. Green is paint and composite; a lot of it means something expensive is being repainted. And the frame boundaries: at 60Hz you have 16.7ms per frame, and anything longer is a dropped frame the user may feel.
Record the same interaction with CPU throttling set to 4x slower. That approximates a mid-range phone, which is what most of your users have. A page that holds 60fps throttled is genuinely fast; one that only holds it on your laptop has not been tested yet.