Inlining Critical CSS on Static Sites

A browser will not paint a page until it has downloaded and parsed every stylesheet linked in the <head>. On a repeat visit that stylesheet is cached and costs nothing. On a first visit over a mobile connection, it costs a full request — DNS is already done, but the round trip and transfer still add hundreds of milliseconds between the HTML arriving and anything appearing on screen. For static sites, whose HTML arrives quickly from the edge, that stylesheet is frequently the single largest delay before First Contentful Paint.

This guide removes that delay in the two ways that work: inline the whole stylesheet when it is small enough, or extract and inline only the critical rules while the full file loads without blocking. It measures both on an Eleventy documentation site and an Astro site, and covers the repeat-visit trade-off that is easy to ignore. It is part of CSS and JavaScript Asset Optimization.

Prerequisites

Step 1: Measure the Blocking Cost

Run Lighthouse on a representative page with the default mobile throttling and look at two things: the "Eliminate render-blocking resources" audit, which estimates the saving, and the gap in the network waterfall between the HTML response ending and the CSS response ending. On the Eleventy site, the HTML finished at 560 ms and the stylesheet at 1,170 ms, and FCP followed at 1.9 s after parsing and font work.

Then check the stylesheet's compressed size — it decides the strategy:

brotli -c -q 11 dist/assets/site.*.css | wc -c     # 11,240 bytes on the Eleventy site
brotli -c -q 11 dist/_astro/*.css | wc -c          # 9,310 bytes on the Astro site after purging
Choosing a critical CSS strategy by stylesheet size The root question is the compressed stylesheet size. Under about 15 kilobytes, inline the whole stylesheet in every page. Between 15 and 60 kilobytes, inline extracted critical rules and load the full file without blocking. Above 60 kilobytes, first remove unused CSS, since extraction from a very large stylesheet tends to miss rules and inflates every page. Inline everything, inline critical, or leave it external? Compressed stylesheet size? Under 15 KB Inline all of it no request, no extraction risk 15 to 60 KB Inline critical rules full file loads non-blocking Over 60 KB Purge first then choose again
The thresholds are rules of thumb from these two sites; the deciding factor is whether the repeated bytes cost less than one round trip.

Step 2a: Inline the Whole Stylesheet

At 11 KB compressed, the Eleventy site simply inlines everything. An Eleventy transform reads the built CSS once and replaces the <link> with a <style> element:

// eleventy.config.js
import { readFileSync } from 'node:fs';
let css;
eleventyConfig.addTransform('inline-css', function (content) {
  if (!this.page.outputPath?.endsWith('.html')) return content;
  css ??= readFileSync('_site/assets/site.css', 'utf8');
  return content.replace(/<link rel="stylesheet" href="\/assets\/site\.css">/, `<style>${css}</style>`);
});

In Astro, build.inlineStylesheets: 'always' does the same for component and page styles; 'auto' inlines only stylesheets below vite.build.assetsInlineLimit. Hugo can inline a processed resource with {{ with resources.Get "css/site.css" | minify }}<style>{{ .Content | safeCSS }}</style>{{ end }}.

If the site has a Content Security Policy, inline <style> elements need a hash or 'unsafe-inline' in style-src. Hashing works well for static sites because the CSS is identical on every page; the approach is the same as for scripts in Hash-Based CSP for Inline Scripts in Astro.

Step 2b: Extract Critical Rules per Page

For stylesheets that are too large to inline whole, Beasties (the maintained successor to Google's Critters) runs over each built HTML file, keeps the rules that match elements in the document, inlines them, and rewrites the stylesheet link to load without blocking:

// scripts/critical.mjs — run after the build
import Beasties from 'beasties';
import { globby } from 'globby';
import { readFile, writeFile } from 'node:fs/promises';

const beasties = new Beasties({ path: 'dist', preload: 'swap', pruneSource: false, fonts: true });
for (const file of await globby('dist/**/*.html')) {
  const html = await readFile(file, 'utf8');
  await writeFile(file, await beasties.process(html));
}

Beasties inlines rules for every element in the document, not only above the fold, which is more robust than viewport-based tools such as critical that render the page in a headless browser: there is no risk of missing a rule because the test viewport was a different size. The cost is a larger inline block. On the Astro site, the per-page inline CSS was 4–7 KB compressed, and preload: 'swap' rewrote the link to <link rel="preload" as="style" onload="this.rel='stylesheet'"> with a <noscript> fallback.

Critical CSS extraction steps per HTML file For each built HTML file, Beasties parses the document, matches every CSS rule against the elements present, inlines matching rules in a style element in the head, rewrites the stylesheet link to preload and swap to a stylesheet when loaded, and leaves a noscript fallback. What Beasties does to each built page Parse HTML built page from dist Match rules selectors vs elements Inline matches 4-7 KB per page Rewrite link preload, then swap noscript fallback full stylesheet
The full stylesheet still loads and is cached; it just no longer blocks the first paint.

Step 3: Check for Flashes and Shifts

Extraction can miss rules that apply only after JavaScript adds a class (an open menu, a theme class on <html>), and those elements then render unstyled until the full stylesheet arrives. Two checks catch this. Visually, load key templates with the network throttled to "Slow 4G" and the full stylesheet blocked in DevTools request blocking — what you see is what a slow first visit shows before the swap. In the field, watch CLS p75 for a week after rollout; on the Astro site it stayed at 0.02, but an earlier attempt without the theme class safelisted produced a flash of light theme in dark mode that readers reported within a day.

Theme toggles deserve special care: the inline script that sets data-theme before paint must come before the inline styles, and the dark-mode rules must be in the inlined set. Beasties' additionalStylesheets and safelisting options cover rules for states not present in the static HTML.

Measured Impact

Lighthouse 12, mobile profile, median of five runs; field p75 from web-vitals RUM over two weeks.

MeasureExternal CSSInline all (Eleventy)Critical inline (Astro)
Render-blocking requests100
FCP, lab1.9 s / 2.2 s1.3 s1.5 s
LCP, lab2.3 s / 2.8 s1.7 s2.1 s
LCP p75, field1.6 s / 2.1 s1.2 s1.9 s
HTML size increase per page (compressed)—+11 KB+5 KB
CLS p75, field0.01 / 0.020.010.02
FCP with external CSS compared with inlined CSS on both sites Paired bars. On the Eleventy site, FCP went from 1.9 seconds with an external stylesheet to 1.3 seconds with the whole stylesheet inlined. On the Astro site, FCP went from 2.2 seconds to 1.5 seconds with critical rules inlined by Beasties. First Contentful Paint, lab, before and after External stylesheet Inlined Eleventy (inline all) 1.9 s 1.3 s Astro (critical via Beasties) 2.2 s 1.5 s
Lighthouse mobile profile, median of five runs on the same build and runner.

The Repeat-Visit Trade-Off

Inlining moves bytes from a cacheable file into every HTML response. For a reader who views one page, that is pure gain. For a reader who views ten pages in a session, the Eleventy site now sends 11 KB × 10 = 110 KB of CSS where an external file would have sent 11 KB once. Documentation sites have long sessions — this one averaged 4.3 pages per session — so the question is whether 34 KB of extra transfer per session matters more than 600 ms off the first paint.

For this site it did not: the extra bytes arrive with HTML that is already downloading, add almost no time on a warm connection, and the first page is where readers decide to stay. But it is worth doing the arithmetic for your own audience. A hybrid approach sets a cookie after the first page view and serves the external stylesheet to returning readers, but on a static site that requires an edge function to vary the HTML; for most sites the simpler rule is to inline small stylesheets and extract critical CSS from large ones.

CSS transfer per average session with external and inlined styles Vertical bars of CSS bytes transferred in an average documentation session of 4.3 pages. External cached stylesheet 11 kilobytes. Whole stylesheet inlined 47 kilobytes. Critical rules inlined plus the cached external file 32 kilobytes on the Astro site. CSS bytes per session of 4.3 pages 11 KB External and cached 47 KB Inline all (Eleventy) 32 KB Critical inline + cached file (Astro)
Compressed bytes; the external file is counted once because it is cached after the first page.

Pitfalls & Rollback

  • Inlining before purging. A 180 KB stylesheet inlined into every page makes things worse; purge first.
  • Missing state rules. Menus, dialogs and theme classes added by JavaScript need safelisting.
  • CSP blocking inline styles. Add a hash for the inline block, or the page renders unstyled in strict-CSP browsers.
  • Double loading. Keep only one path to the full stylesheet; a leftover <link rel="stylesheet"> next to the preload downloads it twice.
  • Rollback: both approaches are post-build transforms. Removing the transform restores the original <link> on the next build.

Conclusion

The render-blocking stylesheet is a first-visit tax that static sites pay needlessly. Inline small stylesheets whole; extract critical rules per page with Beasties when the stylesheet is larger; purge before either. On these two sites that removed every render-blocking request and cut lab FCP by 600–700 ms, for a few kilobytes more per HTML page.

FAQ

What counts as critical CSS?

The rules needed to render the content visible in the first viewport without layout changes, typically the page shell, header, typography and the top of the main content. Everything below the fold, and styles for interactive states, can load later.

When should I inline the entire stylesheet instead?

When it is small, roughly under 15 KB compressed. At that size the cost of repeating it in every page is lower than the round trip a separate request costs a first-time visitor on a mobile connection, and there is no risk of missing rules.

Does inlining CSS hurt repeat visits?

Slightly. Inlined CSS is downloaded with every HTML page, while an external file is cached after the first view. For small stylesheets the difference is a few kilobytes per page. For large ones, keep the external file and inline only the critical subset.

Can critical CSS extraction break a page?

Yes, if rules needed above the fold are missed, which shows as a flash of unstyled or shifting content before the full stylesheet loads. Test extracted pages visually at mobile and desktop widths and watch CLS in the field after rollout.

Filed underHow-tosAstro