Experiments / 10
CSS
Why can 200 KB of CSS cost more than 200 KB of images?
The problem
An image of 200 KB and a stylesheet of 200 KB download in the same time. After that they have nothing in common. The image is decoded and drawn. The stylesheet has to be parsed into thousands of rules and then matched against every element on the page, and it stops the page from painting until that’s done.
CSS is also the one thing that gets quietly re-run. Change a class and the browser may have to work out again which rules apply to thousands of elements. That’s where a small change on a big page turns into a dropped frame.
1 · What a stylesheet costs
Start on “Typical”. Compare the blocked time to the same bytes as an image, then try each of the other setups.
- Rendering blocked for
- 714 ms
- Main thread: parse + style
- 959 ms
- One class change on html
- 335 ms
- Rules
- 20,000
- Download 314 ms
- Parse 400 ms
- Download314 ms vs 314 ms
- Blocks rendering?yes vs no
- Main-thread work959 ms vs about 0 ms
- Toggle a class on <html> (5,000 elements)335 ms
- Toggle a class inside one component (40 elements)3 ms
200 KB of CSS blocks rendering for 714 ms (download 314 ms, parse 400 ms). The same 200 KB as an image downloads in the same 314 ms, then costs about nothing on the main thread, because it neither blocks rendering nor needs parsing and matching against 5,000 elements.
Matching the rules to 5,000 elements (559 ms) now costs more than parsing them (400 ms). Fewer elements, fewer rules and simpler selectors all reduce it.
A class change high in the tree costs 335 ms, which is over the 17 ms frame budget. Doing that on scroll or in an animation loop will drop frames. Scope the change to the component that needs it.
60% of this CSS is never used on the page: 120 KB and about 338 ms of blocking time spent on nothing.
Illustrative rates: about 25 rules per source KB, CSS compresses about 4×, matching cost grows with √rules × elements × selector difficulty.
Things to try
- Look at the image row. The download is identical. The main-thread work isn’t.
- Add
@importlevels. Each one waits for the one before it, so you pay a round trip per level before rendering can even start. - Make selectors harder. Switch from single classes to universal or
:has()selectors and watch the style cost multiply. - Grow the page. Drag the element count up. The style cost scales with it.
- Compare the two class changes. The same toggle costs milliseconds inside a component and hundreds on
<html>. The dashed line is a frame. - Split the media CSS. Tick the box to move print and other-media styles out of the blocking path.
How the browser handles CSS
- Parse. The text becomes a structure of rules. This happens on the main thread, and a stylesheet in
<head>must finish before anything is painted (Experiment 05). - Match. For each element the browser finds the rules that apply. Rules are bucketed by id, class and tag so it doesn’t test every rule against every element. That’s why doubling the rules doesn’t double the cost, but doubling the elements does.
- Invalidate. When a class, attribute or the DOM changes, the browser works out which elements might be affected and re-matches just those. A change near the root can affect everything below it.
- Layout and paint follow for whatever changed. Changing a property that affects size and position triggers layout;
transformandopacityusually don’t.
Selector micro-optimisation is rarely the answer. Modern engines match selectors quickly, and a short, readable selector is worth more than a theoretically faster one. The costs that matter are how much CSS you ship, how big the DOM is, and how widely a change spreads. Measure before rewriting selectors.
What helps
- Ship less. DevTools Coverage shows how much CSS a page never uses. Remove it, or split it by route or component.
- No
@import. Use<link>tags (they download in parallel) or let your bundler combine files. - Split by media.
<link rel="stylesheet" href="print.css" media="print">doesn’t block rendering on screen. - Inline the critical CSS for the first view and load the rest without blocking (Experiment 05).
- Scope changes. Toggle a class on the component, not on
<html>or<body>, especially in code that runs often. - Keep the DOM small. Every element is matched against the rules.
- Skip off-screen work with
content-visibility, below.
2 · Skipping what isn’t on screen
This one is real. It builds thousands of cards in your browser, with and without content-visibility: auto,
and times how long the page takes to render.
- Normal layout
- not run
- content-visibility: auto
- not run
- Faster by
- –
- Elements
- 21,000
- Render everything126 ms · 21,000 elements
- Render what is on screen60 ms · 35 elements
Your measurement will differ: it depends on your device and browser. The shape should match.
It moves the work, it doesn’t delete it. With content-visibility: auto the browser skips style, layout and paint for cards that are off-screen, and does that work for each card as it scrolls close to the viewport. A page that is mostly off-screen starts much faster. The scroll bar needs contain-intrinsic-size to know roughly how tall a skipped card is, or it jumps around as cards are rendered.
Measured with performance.now(): insert the nodes, force style and layout, then wait for the frame to be painted. Median of 3 runs.
content-visibility and containment
.card {
content-visibility: auto;
contain-intrinsic-size: auto 120px; /* a guess at the height until it has been rendered */
}
- With
auto, the browser skips rendering work for elements well outside the viewport and does it as they approach it. The content stays in the DOM, so find-in-page and accessibility tools still see it. - It helps on long pages made of many independent sections: feeds, articles, listings. It does little for a short page, and the work is deferred to scrolling, not removed.
- Without
contain-intrinsic-size, a skipped element has no height, so the scroll bar jumps as things render. Theautokeyword remembers each element’s real size once it has been shown. containis the related tool: it promises the browser that a component’s layout, style or paint doesn’t affect the rest, so changes inside it can be handled in isolation. Measure the effect on your page before assuming it helps.- Support: Chromium has had
content-visibilitysince 2020, and Firefox 125 and Safari 18 added it in 2024. Check support for your audience. It degrades to normal rendering where it isn’t supported.
Common misconceptions
“CSS is just text, so it’s cheap. Images are the heavy part.”
Bytes are the wrong unit again. A stylesheet blocks rendering, then costs main-thread time to parse and to match against your elements, and again every time the DOM changes. An image costs download time and little else.
“I should rewrite my selectors for speed.”
Usually not. Try cutting unused CSS, shrinking the DOM and scoping class changes first. Selector rewrites tend to cost maintainability for gains you can’t measure.
“Put content-visibility: auto on everything.”
It pays for long pages of independent sections. On a short page it adds complexity and layout jumps for nothing. The demo above shows how much it can save, and only on a big page.
See it on a real page
- In DevTools open Coverage, reload, and sort by unused bytes for each stylesheet.
- In the Performance panel, find Recalculate Style events. Their duration, and the “elements affected” count in the details, tell you how wide a change reached. Chrome can also record per-selector statistics from the Performance settings.
- In Network, look at the waterfall for a staircase of CSS requests: that’s an
@importchain. - Lighthouse’s “Reduce unused CSS”, “Eliminate render-blocking resources” and “Avoid chaining critical requests” audits point at the same causes.
Model assumptions
What these simulations simplify
- CSS is assumed to compress about 4× and hold about 25 rules per source KB. Parse costs 0.5 ms per compressed KB on a desktop. Style matching costs 0.0025 ms per element, scaled by the square root of the rule count and by selector difficulty (1× to 9×). All are illustrative.
- Restyling after a class change costs less per element than the first pass, and scales with the number of elements affected: all of them for a change on
<html>, 40 for one inside a component. - Each
@importlevel adds one round trip. Parallel<link>tags are assumed for the flat case. - The content-visibility model renders the visible sections plus a margin of two, and charges a small cost per skipped section. It is calibrated to the order of magnitude browsers report, not to a specific page. The demo beside it measures your actual browser.