How to Extract Critical CSS at Build Time

This guide is part of Critical CSS & Render-Blocking Resources, within Rendering & CSS Performance. Critical CSS — the small set of rules needed to render the first viewport — is the most effective way to remove render-blocking CSS from first paint. It is also easy to get wrong when maintained by hand: a designer adjusts the header, the hand-written critical block does not change, and suddenly the first paint shows a broken header that snaps into place when the full stylesheet loads.

The solution is to generate critical CSS in the build. Tools such as critical (which renders pages in a headless browser at chosen viewports and keeps rules that match visible elements) and Beasties (formerly Critters, which works on static HTML without a browser and inlines rules used anywhere in the document) automate extraction and inlining. The key decisions are which tool to use, which viewports to cover, how to handle multiple templates, and how to verify the result.

critical vs Beasties (Critters) Comparison of a headless-browser critical CSS extractor with a static HTML analyser. critical vs Beasties (Critters) critical (headless browser) • Renders pages at chosen viewports • Keeps only above-the-fold rules • Smaller output, slower builds • Needs a browser in CI Beasties / Critters (static) • Parses HTML, no browser needed • Keeps rules used anywhere in the page • Larger output, fast builds • Easy framework integration

Rapid Diagnosis

  • Check for render-blocking stylesheets in Lighthouse or the Network panel's render-blocking column.
  • Look for an existing inline style block. If it is hand-written, compare it with the current design; stale critical CSS is common.
  • Check build output. Static site generators produce HTML files that are ideal for build-time extraction; SSR apps need extraction per template at build or render time.
  • Measure the full CSS size. If it is very small (under 10KB compressed), inlining it entirely may be simpler than extracting.

Root Cause Analysis

1. Critical CSS is page-dependent. Different templates have different above-the-fold content.

2. Viewports differ. Mobile and desktop first viewports contain different elements.

3. Designs change. Hand-maintained critical blocks fall out of date.

4. Dynamic content. Elements injected by JavaScript are not seen by static extraction.

Step-by-Step Resolution

1. Choose the tool for your stack

For static sites and SSG output, critical gives the smallest output. For frameworks, Beasties integrations exist (Nuxt's experimental.inlineSSRStyles and similar options, Angular's inlineCritical, Astro integrations).

2. Generate per template at multiple viewports

javascript
// scripts/critical.mjs — run after the static build.
import { generate } from 'critical';
const templates = ['index.html', 'blog/example-post/index.html', 'products/example/index.html'];
for (const page of templates) {
  await generate({
    base: 'dist/',
    src: page,
    target: { html: page },               // overwrite with inlined critical CSS
    inline: true,
    extract: false,                        // keep full CSS intact for async loading
    dimensions: [{ width: 360, height: 740 }, { width: 1300, height: 900 }],
    penthouse: { timeout: 60000 },
  });
}
// trade-off: running a headless browser per page is slow; generate per template
// and apply the result to all pages that share it, rather than every URL.

3. Apply per-template output to all pages of that template

For sites with thousands of pages, generate critical CSS for one representative page per template and inject it into every page of that template during the build.

4. Load the full stylesheet asynchronously

critical with inline: true rewrites stylesheet links to load asynchronously (media swap with a noscript fallback). Check the output HTML to confirm.

Build pipeline with critical CSS extraction Build steps from compiling CSS through generating per-template critical CSS to inlining and verifying output. Build pipeline with critical CSS extraction Build CSS compile + purge Build HTML SSG or prerender Extract per template, 2 viewports Inline + async full CSS Check size budget + screenshot

Verification

Check the size of the inlined block per template (target ≤14KB compressed). Compare screenshots of the first paint (DevTools Performance filmstrip, or WebPageTest) with and without the full stylesheet blocked — they should match for above-the-fold content. Confirm in the Network panel that no stylesheet is render-blocking, and that FCP and LCP improved in the lab.

bash
# Quick size check of inlined critical CSS in built HTML.
for f in dist/index.html dist/blog/example-post/index.html; do
  node -e "const h=require('fs').readFileSync('$f','utf8');const m=h.match(/<style[^>]*>([\s\S]*?)<\/style>/);console.log('$f', m? require('zlib').gzipSync(m[1]).length+' B gz':'none')"
done
# trade-off: gzip size approximates what the user downloads; Brotli is smaller
# still, so this check is slightly conservative.

Worked Example: A Documentation Site

A documentation site built with a static generator had one 120KB stylesheet (from a UI framework) blocking every page; mobile FCP p75 was 1.9s. The team added critical to the build, generating critical CSS for four templates (home, docs article, API reference, search) at 360px and 1300px widths. Output blocks were 6–11KB compressed. Builds took 40 seconds longer. FCP p75 fell to 1.1s, LCP p75 from 2.4s to 1.6s. A visual regression job compares first-paint screenshots for each template with the full CSS blocked, catching designs that change faster than the critical CSS.

Inlined critical CSS size by template (compressed) Bar chart of compressed critical CSS size for four templates against a 14KB budget. Inlined critical CSS size by template (compressed) Home 11KB Docs article 7KB API reference 9KB Search 6KB budget

Keeping Critical CSS in Sync

Generated critical CSS is only as fresh as the last build, which is normally fine. Problems arise when components above the fold are rendered differently at runtime than in the build — A/B tests that swap the hero, logged-in headers, cookie banners and personalised recommendations. For each, either include their styles explicitly (most tools accept a list of selectors to force-include) or render them below the fold initially. Add a CI check that fails the build if the inlined block grows beyond the budget; growth usually means a new component landed above the fold or extraction started including too much.

Common Mistakes

  • One viewport only. Mobile-only critical CSS leaves desktop first paint broken, or vice versa.
  • Extracting per URL on large sites. Builds become very slow; extract per template.
  • Removing the critical rules from the full stylesheet. If the full CSS lacks them, later navigations or JS-rendered content can break.
  • Ignoring dynamic above-the-fold content. Banners and personalised blocks flash unstyled.

Edge Cases

Server-rendered apps. Extract at build time for each route template, or use framework features that inline styles used during SSR.

Font-face rules. Include @font-face declarations for fonts used above the fold, with font-display, so text renders with the right fallback strategy.

CSS custom properties. Make sure variables defined on :root that critical rules use are included.

Dark mode. Include prefers-color-scheme rules for above-the-fold elements, or the first paint flashes the wrong theme.

FAQ

Which viewports should I use?

At least one small mobile (around 360×740) and one common desktop (around 1300×900). Add a tablet viewport if tablets are a large share of traffic.

Is Beasties output too large?

It includes rules used anywhere in the HTML, not only above the fold, so output can be larger. For short pages that is fine; for long pages, a viewport-based tool is more precise.

Can critical CSS be generated at request time?

It can, but it is expensive. Cache per template, or generate at build time. Request-time extraction is rarely worth it.

How do I test that critical CSS is complete?

Block the full stylesheet in DevTools (Network request blocking) and load the page. The visible area should look correct.

Does critical CSS help repeat visits?

Less so — the full stylesheet is usually cached. Some sites skip inlining for returning visitors using a cookie, but this adds complexity and caching complications.

What about CSS frameworks with utility classes?

Utility CSS with purging is often small enough that critical extraction is less important; measure first. If the purged file is under about 15KB compressed, inlining it entirely can work.

Does critical CSS work with a CDN-cached HTML page?

Yes. Inlined CSS is part of the HTML, so it is cached with it. Regenerate and purge the HTML cache when templates or styles change, just as you would for any markup change.