Back to Colophon
Back to Colophon

Misconfigured critters was inlining everything. Went looking for alternatives, found archived projects and dated advice, ended up building a Playwright-based critical CSS integration from scratch.

#performance#css#astro#playwright

How I got here#

I had astro-critters set up for critical CSS inlining. Critters is supposed to be target-specific: it matches CSS selectors against the static HTML DOM and only inlines rules that match elements on the page. Sensible approach, except I had it misconfigured and it was inlining basically everything. Footer styles, components way below the fold, the whole lot. First paint was fast, sure, but only because the inline <style> tag was enormous and the external CSS was redundant.

When I realized what was happening, I tried scoping extraction to just the nav with data-critters-container. That fixed the over-inlining but went too far: now the hero, :root variables, @font-face rules, and everything else above the fold was missing. The browser had to wait for the external stylesheet before it could paint anything meaningful.

So I went looking for alternatives. Critters was archived in October 2024. The other tools I found were either abandoned, tightly coupled to webpack, or doing the same DOM-matching approach. The advice I kept finding was dated. Nobody was doing viewport-aware extraction in the Astro ecosystem.

Building the replacement#

Built a single Astro integration that runs on every build. It spins up a static file server pointing at dist/, launches headless Chromium via Playwright, and loads each page at 1920x1080. Inside the browser, it walks every CSS rule and checks whether any matching element is actually in the viewport using getBoundingClientRect(). Rules that pass get collected as critical CSS.

Some rules skip the viewport check entirely: :root and [data-theme] selectors (CSS variables and theming), @font-face (fonts need to start loading immediately), and any selector where querySelectorAll throws (false positive beats a flash of unstyled content).

@keyframes get handled in a second pass. The browser extraction tracks which animation-name values appear in critical rules, then Node pulls matching @keyframes from the full CSS. Same for dark mode variants: for every critical selector, the integration finds its [data-theme="dark"] counterpart and includes it. First paint in either theme looks correct.

Before and after#

Before (critters, misconfigured): Inline <style> contained ~53KB of CSS, basically the entire stylesheet duplicated. External CSS also loaded with the same ~53KB. The browser parsed everything twice.

Before (critters, scoped to nav only): Inline <style> had ~2KB of just nav styles. Everything else, including above-fold hero, fonts, theme variables, waited for the external CSS. Faster first paint but visually broken until the stylesheet arrived.

After (Playwright-based): Inline <style> contains ~19KB of viewport-critical CSS (nav, hero, :root vars, fonts, dark mode). External CSS is pruned to ~45KB per page with zero overlap. The browser paints everything above the fold immediately and loads the rest async.

The about page as an example:

                        Inline CSS    External CSS    Duplicated rules
Critters (misconfigured)  ~53KB         ~53KB           ~all of them
Critters (nav-scoped)      ~2KB         ~53KB           ~2KB
New integration            ~19KB        ~36KB           0

The duplication rabbit hole#

The first working version still had 106 selectors defined in both inline and external CSS. I initially thought this was standard, the way every tool does it. Turns out Addy Osmani’s critical tool has an extract option that does exactly what I wanted: remove inlined rules from external CSS. The “use with caution” warning is about losing cross-page cache sharing, but that argument falls apart when your HTML is cached too. Cloudflare caches the HTML responses, so the inlined critical CSS is already cached as part of the document.

Getting the pruning to actually work was its own problem. The critical CSS comes from browser cssText, which the browser normalizes differently than Vite-minified CSS:

  • 0px vs 0
  • var(--x, 0px) vs var(--x,0)
  • [data-theme="dark"] vs [data-theme=dark]
  • h1, h2, h3 vs h1,h2,h3
  • :after vs ::after

String comparison caught almost none of these. First attempt at pruning removed 0.1KB. The fix came in three rounds of structural matching: compare rules by normalized selector instead of serialized text. Attribute value quotes stripped, comma spacing normalized, single-colon pseudo-elements upgraded to double-colon. For @font-face, match on font-family plus style plus weight. For @media blocks, drill into individual children so partially-critical media queries get their matched rules pruned one by one.

106 duplicates became 30, then 6, then 0.

What it does now#

Every npm run build (and therefore every push):

  1. Playwright renders each page at 1920x1080
  2. Extracts CSS for visible elements, inlines it
  3. Rewrites <link> tags to async load
  4. Generates per-page pruned external CSS with zero duplication
  5. Caches by content hash so unchanged pages skip Playwright on rebuild
  6. Any page can opt out with <meta name="skip-critical-css" />

New pages, new components, new CSS: it just handles them. No config, no manual lists, no special attributes to remember.

Misconfigured critters was inlining everything. Went looking for alternatives, found archived projects and dated advice, ended up building a Playwright-based critical CSS integration from scratch.