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
- A built static site whose CSS you control (not injected by third-party widgets).
- Lighthouse or WebPageTest to measure FCP and LCP on a throttled mobile profile, and field RUM for LCP and CLS, as in Measuring CLS in the Field with web-vitals.js.
- Unused CSS removed first — extracting critical CSS from a bloated stylesheet is harder and less effective; see Purging Unused CSS with Tailwind and Lightning CSS.
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
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.
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.
| Measure | External CSS | Inline all (Eleventy) | Critical inline (Astro) |
|---|---|---|---|
| Render-blocking requests | 1 | 0 | 0 |
| FCP, lab | 1.9 s / 2.2 s | 1.3 s | 1.5 s |
| LCP, lab | 2.3 s / 2.8 s | 1.7 s | 2.1 s |
| LCP p75, field | 1.6 s / 2.1 s | 1.2 s | 1.9 s |
| HTML size increase per page (compressed) | — | +11 KB | +5 KB |
| CLS p75, field | 0.01 / 0.02 | 0.01 | 0.02 |
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.
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.
Related
- Parent: CSS and JavaScript Asset Optimization — the full asset pipeline.
- Eliminating Render-Blocking CSS on Static Sites — the LCP perspective.
- Purging Unused CSS with Tailwind and Lightning CSS — the step before inlining.
- Precompressing Assets with Brotli and Zstd — shrinking the HTML that now carries the CSS.
- Font Display: optional vs swap — the other render-affecting head resource.