Open any page of our marketing site, for example the quiz hosting guide, open the console and paste this:
const sheet = [...document.styleSheets].find(s => s.cssRules.length > 100); const rules = [...sheet.cssRules].flatMap(r => r.cssRules ? [...r.cssRules] : [r]) .filter(r => r.selectorText); const strip = s => s.split(',') .map(x => x.replace(/::?(hover|focus|focus-visible|active|before|after|placeholder)\b/g, '').trim()) .filter(Boolean).join(','); let hit = 0; for (const r of rules) { try { if (document.querySelector(strip(r.selectorText))) hit++; } catch {} } console.log(hit, "of", rules.length); On the guide it prints 213 of 1250. On the pricing page it prints 236. On the cookie policy, which is the thinnest page we publish, it prints 109.
I ran that census over all 79 URLs in our sitemap to find out what the other thousand rules are for.
The file
Two stylesheets, both fetched on every page:
| file | bytes | brotli | style rules |
|---|---|---|---|
| the small one | 4,114 | 1,042 | 4 |
| the big one | 103,484 | 17,422 | 1,250 |
The small one is four rules emitted by next/font: a className and a variable rule per family, for Geist and Geist Mono. The two variable rules match, because that is how the fonts are wired in. The two className rules match nothing on any page of the site, which is a fair summary of what this whole post is about.
The big one is the Tailwind v4 build. 1,250 style rules, 99,765 characters of rule text, median rule 56 characters.
The numbers across 79 pages
- Median page: 213 rules matched, 24,106 characters of rule text.
- Thinnest page (
/legal/cookies): 109. - Heaviest page (the homepage, which carries the product demos): 430.
- Matched by at least one of the 79 pages: 502 rules, 44,010 characters.
- Matched by none of them: 748 rules, 55,755 characters, 56 per cent of the file's rule text.
- Matched on every single page: 87 rules, 14,806 characters. That is the real shared base: the preflight,
:rootand:hostwith the theme variables, the heading and anchor resets, the layout utilities the header and footer use.
So the number that matters is not "how much CSS does this page use", it is the shape of the distribution: a small base that every page needs, a hundred or so rules that vary by page, and a large tail nobody on the public site ever selects.
What the 748 are
729 of them are class-based utilities. 19 are element or structural rules.
The 19 are the easy ones to explain, because they come from Tailwind's preflight and they name elements our content pages simply do not contain:
hr abbr:where([title]) code, kbd, samp, pre small sub, sup progress summary textarea :where(select:is([multiple], [size])) optgroup That is a nice second opinion on an HTML census I ran recently, which found zero details, zero figure, zero blockquote and zero hr across the public pages. The stylesheet agrees, independently.
The 729 utilities are the interesting half. They are the signed-in product: positioning utilities (.top-1/2, .right-[85%], .bottom-[70%], .-inset-[15%]), .sr-only, .invisible, .collapse, and 72 of the 101 rules that reference var(--u), which is the unit the host screen sizes itself in. One build emits one stylesheet for the whole app, the marketing pages are a small part of that app, and the marketing pages are what gets crawled and shared.
Three ways to read that:
- It is waste: every visitor to a guide page downloads the CSS for screens they will never see.
- It is a cache: one file, one URL, immutable, fetched once and then reused across every page of the site and every screen of the product. Per-route CSS would mean a new file per route boundary and a new request each time somebody crosses one.
- It is 17,422 bytes over the wire. The honest size of the problem.
We are at (2) and (3) on purpose, and I would not split it. But I would not have guessed "more than half of it never matches anything a visitor can reach" before measuring, and that number is the one worth knowing when somebody proposes adding a component library.
Chrome's Coverage panel says 93 per cent used, and it is wrong
The obvious objection to all of the above is that DevTools already has a Coverage panel, so why write a selector matcher. Because on a Tailwind v4 build the panel lies, and it lies in our favour, which is worse.
I ran the real thing over CDP: CSS.startRuleUsageTracking, navigate, CSS.stopRuleUsageTracking. On our FAQ page it reports 362 used ranges, and when you merge them you get 96,270 of 103,484 bytes used. 93 per cent.
Here is the top of the list of ranges it reported, sorted by size:
13927 - 98283 84,356 bytes 13927 - 98283 84,356 bytes 13927 - 98283 84,356 bytes ... That range is @layer utilities { ... }. Every used rule inside the layer is reported with the layer block as its range, so one matched utility marks 84 KB as used. Tailwind v4 wraps everything in @layer, so the byte-based coverage number for this file is approximately "did anything at all match".
The same artifact makes the top-level byte count misleading in the other direction, by the way. Five rules in this file are enormous: two :root, :host blocks of 3,541 and 2,909 characters, the *, ::before, ::after, ::backdrop defaults block at 1,997, and the .light and .dark palettes at 785 and 607. Those five match on every page and are 9.8 KB on their own. Count bytes and the file looks well used. Count rules and you see what is actually going on.
The method, including the bits that are not clean
- Selector matching is not usage. A rule whose selector matches an element is counted, whether or not it changed how anything looks, and a
:hoverrule is counted if its base element exists, because I strip state pseudo-classes before testing. This measures "could this rule apply to this document", which is the question I wanted. - 33 selectors in the file cannot be tested this way at all: pseudo-element constructs such as
::-webkit-datetime-edit-millisecond-field, and a handful of:is(:where(.group...))group variants. Stripping leaves something the selector parser rejects. I counted all 33 as matches, which means 748 is a floor, not a ceiling. - Our homepage redirects signed-in visitors to their dashboard, and the browser profile I measure with is signed in, so I could not navigate to it. I fetched its HTML anonymously, replaced the document of a live page with it, waited for the same two stylesheets to load, and ran the census against that. Scripts do not run through
innerHTML, so anything the homepage adds in JavaScript is missing from its 430. That number is also a floor. - The counts come from one deploy,
dpl_3kqdZAyQwMYueCBE8FzgFwGo1KAe, on a site with 79 URLs in the sitemap, 77 of them prerendered.
Check it yourself
Paste the snippet above into the console on any of these and watch the first number move while the second never does:
- /faq: 214
- /features: 212
- /pricing: 236
- /tools/round-planner: 237, the most interactive public page we have
- /legal/cookies: 109
If you run it on your own site and the second number is small, you have per-route CSS or a very disciplined design system. If it is large and the first is a seventh of it, you have one stylesheet for an application, and most of your visitors are reading the brochure.
