Medium wave · 540–1600 kHz79 stn
1567kHz

Critical CSS Extraction Solves a Problem You Don't Have

A towering brass cutting machine in a fog-filled steampunk foundry, dozens of clockwork arms painstakingly slicing a single glowing neon-purple sheet into tiny fragments while an identical intact sheet already sits finished and gleaming on the tray beside it, teal and amber light reflecting off wet copper floors

Lighthouse says “Eliminate render-blocking resources” and points at your stylesheet. A quick search turns up the canonical answer: extract the critical CSS, inline it in the <head>, and load the rest asynchronously. There’s a plugin for that. You install it, the audit goes green, and you move on.

What you actually did was add a headless-browser step to your build, ship a second copy of your most important rules on every page, introduce a flash of unstyled content below the fold, and quietly require an inline event handler that your Content Security Policy should be rejecting.

All to avoid blocking on a file that, on most static sites, is smaller than a single hero image.

Critical CSS extraction is a real technique for a real problem. The problem is a 300 KB stylesheet built by twelve teams over eight years. If that isn’t your site, the technique is overhead with a green checkmark attached.


What extraction actually does

The tools in this space — critical, Penthouse, and Critters (now maintained as a fork called Beasties, which is what Next.js reaches for behind experimental.optimizeCss) — all do roughly the same thing:

  1. Render each page, either in a headless browser at a fixed viewport or by statically matching selectors against the HTML.
  2. Collect the rules that apply to elements visible “above the fold.”
  3. Inline those rules in a <style> block in the <head>.
  4. Rewrite the original <link rel="stylesheet"> so it no longer blocks rendering.

Step four is where it gets interesting. There’s no native “load this stylesheet but don’t block on it” attribute, so the tooling uses a trick:

<style>
  /* ~6 KB of extracted "critical" rules */
</style>
<link rel="stylesheet" href="/styles/site.css" media="print" onload="this.media='all'" />
<noscript><link rel="stylesheet" href="/styles/site.css" /></noscript>

A stylesheet with media="print" doesn’t block screen rendering, so the browser fetches it at low priority. When it finishes, a JavaScript onload handler flips the media query and the full stylesheet applies.

Every part of that snippet is a cost. Let’s go through them.


Cost one: “the fold” is a guess

There is no fold. There’s a 375-pixel-wide phone, a 1440-pixel laptop, a 2560-pixel monitor with the browser at half width, and a tablet in landscape. Extraction tools pick one or two viewport sizes and treat whatever is visible there as critical.

Anything the guess misses renders unstyled until the full stylesheet arrives and applies. On a fast connection that window is short enough that nobody notices. On a slow one, a reader on a tall monitor sees the bottom third of your article snap from browser defaults into your typography — which is exactly the kind of shift that counts against Cumulative Layout Shift.

You traded a render-blocking stylesheet for a guaranteed reflow on every viewport the tool didn’t render. That’s not obviously a win, and Lighthouse won’t tell you, because Lighthouse runs at the viewport the tool was probably configured for.


Cost two: the loader needs JavaScript your CSP shouldn’t allow

That onload="this.media='all'" is an inline event handler. A strict Content Security Policy blocks inline handlers by default. To allow it you need 'unsafe-inline' in script-src, or 'unsafe-hashes' plus a hash of the handler source.

If you’ve done the work to ship a strict CSP on your static site, the critical CSS plugin is now asking you to loosen it. If you haven’t, the plugin is one more reason you never will.

And when the handler is blocked — by a CSP, by a script error, by a browser extension — the full stylesheet stays applied as print forever. The <noscript> fallback doesn’t help, because JavaScript is enabled. It just didn’t run that one attribute. Your page renders with only the critical subset, permanently, and nothing in your build will catch it.


Cost three: you’re shipping the same rules twice

The extracted rules don’t move out of your stylesheet. They’re copied. The <style> block holds the critical subset, and site.css still holds everything, including that subset.

On a first visit, the reader downloads your header, nav, and typography rules twice. On every subsequent page view, they download the inlined copy again — because inline CSS lives in the HTML, and your HTML is (correctly) served with a short cache lifetime so deploys show up.

Compare that with the boring alternative: one fingerprinted stylesheet served with Cache-Control: public, max-age=31536000, immutable. First page view, it blocks rendering briefly. Every page view after that, it costs zero bytes and zero round trips. For a content site where readers click through two or three articles, the external file wins on total bytes almost immediately.


Measure before you optimize

The entire argument for extraction rests on your stylesheet being big enough that blocking on it hurts. So check:

npm run build
for f in dist/_astro/*.css; do
  printf '%7d bytes gzipped  %s\n' "$(gzip -9c "$f" | wc -c)" "$f"
done

Adjust the path for your framework (.next/static/css/ for Next.js). Then compare against one number: roughly 14 KB. A new TCP connection starts with an initial congestion window of ten segments, which is about 14 KB of payload in the first round trip. Your HTML and CSS together fitting in that window is the classic rule of thumb for “renders in one trip.”

It’s a heuristic, not physics. HTTP/2, TLS, and QUIC all blur it. But it’s a useful line: if your CSS for a page is a few kilobytes compressed, the render-blocking fetch for it is not your performance problem, and no amount of extraction will make it one.

Most utility-first or component-scoped static sites land well under that line. Tailwind with content scanning typically produces single-digit kilobytes gzipped for a blog. Astro scopes component styles and bundles them per page. If you’re seeing numbers like that, you’re done measuring.


What to do instead

If your CSS is small: inline all of it, or none of it

Astro already makes this decision for you. The build.inlineStylesheets option defaults to 'auto', which inlines any stylesheet smaller than Vite’s assetsInlineLimit (4 KB by default) and links the rest. If your total CSS is small and you’d rather skip the extra request entirely, opt in to inlining everything:

// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
  build: {
    inlineStylesheets: 'always',
  },
});

That gives you what critical CSS promises — no render-blocking request — without the viewport guess, without the loader, and without duplicating anything. The tradeoff is the same caching cost as before, but now you pay it once instead of twice, and on a small stylesheet it’s a rounding error.

The opposite choice is also fine: 'never', one fingerprinted file, immutable caching. Both are defensible. The hybrid that extraction tools produce is the one that combines the downsides of each.

If your CSS is big: shrink it, don’t slice it

A large stylesheet on a static site is usually a symptom: a global bundle carrying rules for every page, a component library imported whole, or a reset and utility layer that never gets purged. Each of those has a direct fix.

  • Split by route. Astro and Next.js both bundle CSS per page or layout when you import it from components instead of one global entry. Pages that don’t render the carousel shouldn’t download its styles.
  • Purge unused rules. If you’re on Tailwind, make sure the content sources actually cover your templates. If you’re not, find the third-party stylesheet you imported for one component.
  • Delete dead CSS. Chrome DevTools’ Coverage panel shows exactly which bytes of each stylesheet were used on a page. Run it on your five most common templates.

These changes make every page faster on every viewport, with no runtime trick and nothing for your CSP to object to.

When extraction does earn its keep

There are sites where extraction is the right call: large e-commerce or media properties with stylesheets in the hundreds of kilobytes, owned by many teams, where splitting is a multi-quarter project and first-visit render time is directly tied to revenue. For those, a well-configured extraction step — tested across multiple viewports, with the loader allowed by a hashed CSP — is a reasonable stopgap.

Note the word stopgap. Even there, it’s a way to live with the problem while you fix the stylesheet, not the fix itself.


The takeaway

“Eliminate render-blocking resources” is an audit, not a mandate. A render-blocking stylesheet is only a problem when it’s large, and on a static site with scoped or purged styles, it almost never is.

Measure your CSS first. If it’s small, inline all of it or cache one file forever, and skip the extraction plugin. If it’s large, the answer is less CSS, not a cleverer way to load the same amount. Either way, you end up with a simpler build, a stricter CSP, and a page that looks right at every width, not just the one a headless browser happened to pick.