01 — Render-Blocking CSS
Your stylesheet is 180KB. Your First Contentful Paint is 1.8s. Your budget is 0.6s. The browser is staring at a blank screen — and it's CSS, not JavaScript, that's stalling it.
The HTML parser finishes at 240ms. The DOM tree is built. The text is decoded. Images are queued. But the browser refuses to paint a single pixel because a flash of unstyled content looks worse than waiting. CSS is render-blocking by design — and that's a different beast from parser-blocking.
A synchronous <script> is parser-blocking: it freezes DOM construction until the script finishes. CSS doesn't do that. The parser keeps going and builds the DOM behind the stylesheet's back. What CSS holds up is the first paint — the browser will not flush pixels to the screen until every rule in every <link rel="stylesheet"> is downloaded, parsed into the CSSOM, and merged with the DOM.
The media-attribute trick is the workhorse:
media="print" tells the browser this sheet doesn't apply to screens, so it doesn't block the paint. The onload handler flips it back to media="all" when the download completes, and the rules apply retroactively. Add fetchpriority="low" on Chromium (Chrome 102+, Safari 17.2+) to keep this load from competing with your LCP image. The <noscript> fallback covers users who disabled JavaScript.
Watch the panel toggle between the three modes — default (1.8s), deferred (0.3s, unstyled paint), and inline critical + deferred rest (0.6s, fully-styled paint). The “deferred only” option is the trap: it ships an unstyled flash, then snaps to styled when the sheet arrives. Inline-critical is the only path that hits 0.6s and paints styled content.
02 — Specificity Calculator
Before pruning a single rule, you need to know which ones actually win. CSS specificity is a three-column count — [id, class, element] — compared lexicographically. One ID outweighs any number of classes. One class outweighs any number of elements. [1, 0, 0] beats [0, 99, 0] every time.
This is why the framework wins. A team ships .button { padding: 12px 24px } in components.css at [0, 1, 0]. Someone writes .button.button.button { padding: 16px 32px } to override it — also [0, 3, 0]. Then six months later a framework upgrade ships #hero .button { ... } at [1, 1, 0]. The triple-class hack collapses overnight. That is the failure mode specificity exists to model.
A few rules that catch people out:
When two rules tie on specificity, the one that appears later in source order wins. That's also the silent failure mode — someone authors a sensible .card rule in components.css, and a later-loaded utilities.css overrides it. Cascade layers (step 5) make this controllable instead of an ordering accident.
The reason this matters for the audit: a high-specificity rule with a selector that never actually matches is still dead code, but a low-specificity rule that's beaten by another is also effectively dead. You cannot strip safely without reading the cascade. Type two selectors into the calculator on the right and watch the columns animate to their tuples; the WINS badge marks the rule that would prevail.
03 — Critical CSS Extractor
Here's the surprise that motivates everything. Of the 180KB stylesheet, what share is actually needed for the first paint? Most engineers guess 60-80%. Pick a number before you read on.
The eight rules above the fold — nav, nav-links, hero, hero heading, hero image, CTA, root tokens, and the type rule — are the only ones the user sees in the first second. Inline those, defer the other seven, and the browser stops waiting:
Tap Extract critical CSS in the panel. The eight green rules collapse into the inline <style> block; the seven below-fold rules get crossed out — still going to load, just behind the paint. The FCP gauge drops from 1.80s to 0.62s. Budget met.
In production, tools like Critters (used by Next.js when experimental.optimizeCss is on), its maintained fork Beasties, or Penthouse do this for you. They render the page in a headless browser at the target viewport, walk the computed styles of every visible element, and emit the matching rules as inline CSS. Do not roll your own — the corner cases (media queries, animations triggered by above-fold elements, font dependencies) are genuinely hard.
Common pitfall: critical CSS is per-viewport. Mobile users see different content above the fold than desktop users. Either ship a single conservative critical block sized for the smallest device, or detect device class on the server and swap blocks. When not to use it: if your CSS is already small (<15KB), or if you're using a zero-runtime CSS-in-JS framework that already inlines critical styles at SSR time, skip the build complexity — the gain is marginal.
04 — Unused CSS Detector (and the JS Trap)
You've inlined the critical 20% and deferred the rest. Now you want to shrink the deferred sheet. You open Chrome DevTools Coverage. It tells you 42% of the bytes are unused. Delete them?
Don't. Not without reading further.
DevTools Coverage is a static snapshot of "which rules match the DOM right now." But the DOM at page load doesn't include states that haven't happened yet. The modal isn't open. The theme isn't dark. The toast hasn't popped. The form input isn't focused. The drag handle hasn't been grabbed. Every CSS rule that styles those states will be flagged as unused — and if you trust the report and delete them, the page breaks on the first user interaction.
The audit needs two snapshots, not one. Drive every interactive state, then take Coverage again. Anything unused across both snapshots is genuinely dead:
Better still: ship CSS in feature-scoped chunks (modal.css, theme.css, forms.css) and load each with <link rel="preload" as="style"> plus a media-attribute switch flipped when the feature activates. Each chunk becomes a single point of audit, and you never ship dark-mode CSS to users who haven't toggled the theme.
In the panel, click each red-flagged rule and decide: safe to remove, or needed by JS. The lab tracks your audit. If you remove a JS-toggled rule, the simulated page on the right breaks visibly — the modal renders in the top-left corner, the dark-mode bar flashes white, the toast appears without its transition. Correct audits strip the eight truly-dead rules and leave the six JS-toggled ones alone.
The Web Almanac 2024 found the median page ships 67KB of unused CSS. The number isn't wrong — blind static analysis on it is. Two snapshots take a Saturday morning to set up and save you from a Monday-morning customer support storm.
05 — Modern CSS Performance
Three CSS properties shipped in the last 30 months change how to think about CSS performance.
@layer cascade layers (Baseline 2023): wrap rules in named layers, and the cascade ranks layers before specificity. A low-specificity rule in overrides wins over a high-specificity rule in framework — no !important, no .component.component.component arms race. Author order inside a layer is the only thing that still matters within it.
That button element selector at [0, 0, 1] beats the layered .button class at [0, 1, 0] because the entire overrides layer outranks framework, regardless of specificity. Inside a layer, the usual rules apply. Across layers, the layer order is king.
content-visibility: auto shipped in Chrome 85 (Aug 2020), Firefox 125 (April 2024), and Safari 18 (September 2024) — that's Baseline 2024. Use it on any long scrolling list: card grids, table rows, blog post listings. Pair with contain-intrinsic-size: auto 480px so the browser reserves scroll space at the size of the last render. The auto keyword is the modern form — the bare 0 480px syntax requires you to guess.
A sibling: content-visibility: hidden is the always-skip variant — the subtree is never rendered until you remove the property. Use it for tab panes parked in the DOM so they can be swapped in instantly (no remount cost), or for offscreen modals that should keep their state across opens.
view-transition-name (Chrome 111+, Safari 18+): named participants in the View Transitions API. Each named element gets a snapshot before the DOM mutation and another after; the browser interpolates the two on the compositor. Powerful, but it's a new compositor cost — slap view-transition-name: card-{id} on 200 list cards and the browser is now taking 400 snapshots when the list re-orders. Budget it. Use named transitions for the 3-5 elements that benefit from visual continuity (page title, hero image, primary action), not as confetti.
Two more worth knowing:
fetchpriorityon<link rel="stylesheet">— Chromium 102+ (May 2022), Safari 17.2 (Dec 2023). Set the deferred non-critical stylesheet tofetchpriority="low"so it doesn't compete with the LCP image's network budget. The browser preload-scanner already prioritises CSS over images by default; this just leans the dial further.- CSS-in-JS runtime cost. Libraries that inject rules at hydration time (
styled-components,@emotion/styled) run that work on the main thread after the JS bundle parses, which delays both FCP and INP. Zero-runtime equivalents (vanilla-extract,linaria, Tailwind v4) compile to a static.cssfile at build time — the browser treats them like any other stylesheet, no hydration penalty, no main-thread cost.
Toggle the modes in the panel to see the layer cascade resolve overrides, the off-screen subtree skip its render cost, and the CSS-in-JS comparison hit the main thread.
06 — WINS Recap & FCP Budget
The unified scenario is closed: 180KB sheet, 1.8s FCP, 0.6s budget. The fix is three concrete moves you can ship on any project this afternoon.
- Inline the critical 20%. Run Critters or Beasties at build time, pinned to your above-the-fold viewport (640×360 mobile, 1280×720 desktop). Ship the rest with the
media="print" onload="this.media='all'"trick. Adds ~36KB to the HTML response and removes ~1.2s from FCP. - Audit unused CSS with two snapshots. One snapshot is a trap — drive every interactive state (modal, theme, focus, drag) before you trust the Coverage report. Anything still unused across both snapshots is genuinely dead. Or skip the audit entirely by shipping CSS in feature-scoped chunks loaded on demand.
- Adopt
@layerandcontent-visibility. Cascade layers cut the cost of every future override decision.content-visibility: autohalves initial render time on long pages with one line of CSS. Both are Baseline 2023-2024.
When the WINS badge lights up at the top of the lab — render-blocking handled, critical extracted, unused audited, modern features adopted — that budget pass is roughly equivalent to a Lighthouse Performance score climbing from the 60s to the 90s on a real-world e-commerce page.
Three closing notes worth carrying into the next lesson:
- Specificity is the foundation. You cannot audit safely without it.
- Render-blocking is the headline. First paint is what users feel.
- JS-toggled CSS is the hidden gotcha. Static coverage misses it. Two snapshots — or feature-scoped chunking — fix it.