Critical CSS Extraction: A Practical Guide
Inlining critical CSS is one of the highest-leverage techniques available for improving First Contentful Paint and Largest Contentful Paint. A single synchronous stylesheet link can add one or more full network round-trips before the browser renders a single pixel. Extracting the above-the-fold styles and placing them directly in the document eliminates that wait entirely.
This guide covers what critical CSS actually is, why it matters for LCP and FCP, the best extraction tools for different stacks, framework-native integrations, and a manual workflow for complex multi-route sites. It also covers the pitfalls that cause teams to introduce new bugs while chasing the optimization.
<style> tag in <head>. Load the full stylesheet asynchronously with rel="preload" and an onload swap. Keep inlined CSS under 10KB compressed to fit within the 14KB TCP initial congestion window. Use Critters, Beasties, or Penthouse to automate extraction per route. Validate with Lighthouse FCP/LCP scores and Chrome DevTools Coverage.
Expected results
Following all steps in this guide typically produces these improvements across a broad range of site types:
Before
3.8s
FCP (Poor) -- render-blocking stylesheet delays first paint by one or more network round-trips
After
1.2s
FCP (Good) -- inlined critical CSS enables first paint without waiting for external stylesheets
What "critical CSS" actually is
Critical CSS is the minimum set of CSS rules required to render the above-the-fold portion of a page -- the content visible in the viewport before any scrolling occurs. It is not a compressed or minified version of all your styles. It is a strict subset: only the declarations that control the layout, typography, color, and visual appearance of elements present in the initial viewport.
The distinction matters because a typical production stylesheet is enormous. A mature Next.js application might ship 200-400KB of CSS covering every component across every route. Of that total, perhaps 8-15KB applies to what a user actually sees at load time on any given page. Critical CSS extraction identifies that 8-15KB and separates it from the rest.
Above-the-fold content is viewport-relative, not fixed. On a product detail page, the critical content is the hero image, product title, price, and add-to-cart button. On a blog post, it is the article header, byline, and first two paragraphs. On a marketing homepage, it is the nav, headline, subheadline, and CTA button. Each page type has a different critical CSS fingerprint, which is why per-route extraction produces better results than a single shared critical file.
CSS rules that control below-fold elements -- footers, comment sections, sidebar widgets, secondary navigation -- are not critical. Loading them asynchronously after first paint does not affect FCP or LCP scores, because those elements are not part of the initial render.
Why extraction matters for LCP and FCP
The browser's rendering pipeline requires CSS before it can paint. When it encounters a <link rel="stylesheet"> element, it pauses rendering completely until that stylesheet is downloaded and parsed. This is called render-blocking behavior, and it is the most direct cause of poor FCP scores. For a deep look at this problem, see the render-blocking CSS fix guide.
Even on a fast connection, a stylesheet request adds at minimum one round-trip: the browser sends the request, the server responds, and the browser parses the response before rendering begins. On a 75ms RTT connection (typical US mobile), that is 75ms of preventable delay. On a 200ms RTT connection (international mobile or congested network), it is 200ms. Multiply by the number of stylesheets and the impact compounds.
The 14KB initial congestion window compounds the problem further. TCP slow start limits the first network round-trip to approximately 14KB of data. If your HTML plus any inlined CSS fits within 14KB, the browser receives everything it needs to begin painting in a single round-trip. If it does not -- or if you have an external stylesheet link -- the browser must wait for additional round-trips before paint can start.
Inlining critical CSS eliminates the blocking request entirely. The styles arrive with the HTML document in the first 14KB chunk. The browser parses HTML and CSS simultaneously and paints immediately. The full stylesheet loads in the background, asynchronously, without affecting the initial render. This is the single most direct path to improving FCP, and it typically produces 0.5-2.0 second improvements in time-to-first-paint, which flows directly into better LCP scores when the LCP element is above the fold.
The impact is especially pronounced for LCP when the largest element is a styled component -- a headline, a hero card, a CTA -- rather than an image. If the LCP element is a text node or background-styled div, its render depends entirely on CSS being available. Inlining the relevant rules makes those elements paint in the first frame.
Automated extraction tools
Several mature tools automate critical CSS extraction. Each has different trade-offs in speed, accuracy, and integration complexity. Choose based on your build system and tolerance for headless browser overhead.
Critters is a webpack plugin (and Vite plugin) that extracts critical CSS at build time without a headless browser. It parses your HTML output, identifies which CSS rules match elements in the markup, and inlines them. Because it works statically on the HTML AST, it is extremely fast -- adds only seconds to build time regardless of site size. The trade-off is that it cannot account for JavaScript-rendered content: components that render client-side are invisible to Critters at extraction time. Use Critters for server-rendered or statically generated sites where the build output contains the full above-the-fold HTML.
Beasties is the maintained successor to Critters, developed by the Angular team. It shares the same static-parsing approach but has better support for modern CSS features including CSS custom properties, nesting, and layer rules. Beasties is the recommended choice for new projects using webpack or Rollup. The API is nearly identical to Critters, so migration is straightforward.
Penthouse takes a different approach: it launches a headless Chromium instance, loads each page at a specified viewport size, and uses the browser's own CSSOM to determine which rules apply to visible elements. This produces the most accurate possible output, including CSS for JavaScript-rendered content. The cost is build time: Penthouse adds 5-30 seconds per page depending on page complexity. It is the right choice for SPAs where above-the-fold content is rendered client-side, or for sites where content editors control which elements appear in the hero.
Critical (the npm package by Addy Osmani) wraps Penthouse with a more ergonomic API, supports multiple concurrent page extraction, and can write output directly to HTML files or emit separate CSS files. It is suitable for Node.js build scripts and Gulp pipelines. Like Penthouse, it requires headless Chrome, so CI environments must have Chrome installed.
PurgeCSS solves a different but related problem: removing unused CSS rules entirely from the full stylesheet, rather than extracting above-the-fold rules. It does not inline styles; it reduces the size of the external stylesheet that loads asynchronously. Used together with Critters or Beasties, PurgeCSS reduces the async payload while the critical extraction handles first-paint performance. Use PurgeCSS to remove utility classes and component styles that are never rendered on any page, then run Critters to inline the above-the-fold subset.
# Beasties (Critters successor) for webpack/Vite
npm install --save-dev beasties
# Critical (Node.js CLI + API, uses headless Chrome)
npm install --save-dev critical
# Penthouse (headless browser, low-level)
npm install --save-dev penthouse
# PurgeCSS (unused CSS removal)
npm install --save-dev @fullhuman/postcss-purgecss
Framework integrations
Modern frameworks provide first-class critical CSS support. Using framework-native solutions is almost always preferable to manually wiring up a standalone tool, because they handle route detection, cache invalidation, and streaming correctly.
Next.js has shipped automatic critical CSS extraction since version 9.3, enabled by default with no configuration required. During the build, Next.js identifies which CSS modules are imported by each page component and inlines the relevant styles. The implementation uses a modified version of Critters internally. You benefit from this automatically when using CSS Modules or global CSS imports. CSS-in-JS libraries like styled-components require their own SSR style collection approach, described in the FAQ below.
Astro produces zero client-side JavaScript by default and compiles component styles to static CSS at build time. Because Astro knows exactly which components render on each page, it can scope and inline styles with surgical precision. The is:inline directive forces styles into the HTML document head rather than a separate file. Astro's approach to style locality makes it one of the easiest frameworks to achieve optimal critical CSS without any additional tooling.
Nuxt 3 provides a inlineStyles option (enabled in Nuxt 3.8+) that extracts and inlines critical CSS per route during the build. Combined with the Nuxt CSS module system and Vite's style handling, it produces accurate per-route inline blocks. For older Nuxt versions, the critters module provided the same functionality via the Critters webpack plugin.
Eleventy supports critical CSS extraction through the community eleventy-critical-css plugin, which wraps the Critical package and runs extraction as a post-processing step after the static site build completes. It reads each generated HTML file, extracts critical CSS, and rewrites the file with the inlined styles and async stylesheet loading pattern. The plugin is well-suited to documentation sites and content-heavy static sites built on Eleventy.
For webpack projects outside a specific framework, critters-webpack-plugin (or its Beasties equivalent) integrates directly into the webpack plugin array. It runs after HtmlWebpackPlugin generates the HTML output and rewrites each emitted HTML file with inlined critical styles.
const Beasties = require('beasties-webpack-plugin');
module.exports = {
plugins: [
new HtmlWebpackPlugin({ template: './src/index.html' }),
new Beasties({
// Inline styles for above-the-fold content
preload: 'swap',
// Do not inline fonts; use font-loading fix separately
fonts: false,
// Reduce inlined CSS to the exact above-fold rules
pruneSource: true,
// Merge multiple style tags into one
mergeStylesheets: true,
}),
],
};
---
// src/layouts/Base.astro
// Astro scopes component styles automatically.
// Use is:inline to force critical styles into the document head.
---
<html>
<head>
<style is:inline>
/* Critical above-fold rules -- inlined at build time */
:root { --color-bg: #fff; --color-text: #0f172a; }
body { margin: 0; font-family: system-ui, sans-serif; background: var(--color-bg); color: var(--color-text); }
.hero { display: grid; place-items: center; min-height: 60vh; }
.hero__title { font-size: clamp(2rem, 5vw, 4rem); font-weight: 800; line-height: 1.1; }
</style>
<!-- Full stylesheet loads asynchronously -->
<link rel="preload" href="/styles/main.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles/main.css"></noscript>
</head>
<body><slot /></body>
</html>
// nuxt.config.ts
export default defineNuxtConfig({
// Enable per-route critical CSS inlining (Nuxt 3.8+)
features: {
inlineStyles: true,
},
// Optional: configure Vite CSS handling
vite: {
css: {
devSourcemap: true,
},
},
});
Manual extraction workflow for complex sites
Automated tools work well for homogeneous sites with consistent templates. Complex sites -- those with dozens of distinct page types, CDN-driven personalization, or A/B tested layouts -- often need a manual workflow that gives teams full control over what gets extracted, stored, and invalidated.
The first step is route classification. Group your pages into templates: homepage, listing page, detail page, checkout, account, error. Each template has a distinct above-the-fold layout that requires its own critical CSS file. Avoid trying to create a single universal critical CSS file that covers all page types; it will either be too large (defeating the purpose) or too sparse (missing rules for some templates).
For each template, run extraction using Critical or Penthouse at your representative viewport sizes. Store the output as versioned CSS strings associated with the route pattern. A CDN configuration, a simple key-value store, or a build artifact directory all work. The key requirement is that template servers can retrieve the correct critical CSS for each route at response time.
// scripts/generate-critical.mjs
import critical from 'critical';
import { writeFileSync } from 'fs';
const routes = [
{ name: 'home', url: 'http://localhost:3000/', template: 'home.html' },
{ name: 'article', url: 'http://localhost:3000/blog/sample/', template: 'article.html' },
{ name: 'product', url: 'http://localhost:3000/shop/item/', template: 'product.html' },
];
for (const route of routes) {
const { css } = await critical.generate({
src: route.url,
width: 1300,
height: 900,
// Also extract for mobile viewport
dimensions: [
{ width: 375, height: 812 },
{ width: 1300, height: 900 },
],
inline: false, // Return CSS string, not modified HTML
});
// Store per-route critical CSS as versioned artifact
writeFileSync(`./critical/${route.name}.css`, css);
console.log(`Generated ${css.length} bytes for ${route.name}`);
}
Cache invalidation is the most operationally complex part of a manual workflow. The safest approach is content-hash-based cache keys: hash the critical CSS string and include the hash in the template, then instruct the CDN to cache the page forever at that URL. When the critical CSS changes, the hash changes, the URL changes, and the CDN automatically serves the new version. This is equivalent to the content-hash approach used for static assets in webpack and Vite.
For sites using a CDN API (Fastly, CloudFront, Cloudflare), you can store critical CSS per route as a CDN edge dictionary or KV store entry. The edge worker reads the route pattern from the incoming request URL, fetches the corresponding critical CSS from the KV store, and injects it into the HTML response as a <style> tag before forwarding to the origin. This eliminates even the origin processing overhead. See the performance budget tool for tracking size constraints across routes.
// Cloudflare Worker script
export default {
async fetch(request, env) {
const url = new URL(request.url);
// Classify route
let routeKey = 'home';
if (url.pathname.startsWith('/blog/')) routeKey = 'article';
if (url.pathname.startsWith('/shop/')) routeKey = 'product';
// Retrieve critical CSS from KV store
const criticalCSS = await env.CRITICAL_CSS.get(routeKey);
// Fetch origin response
const response = await fetch(request);
const html = await response.text();
// Inject before
const injected = criticalCSS
? html.replace('</head>', `<style>${criticalCSS}</style></head>`)
: html;
return new Response(injected, {
headers: response.headers,
});
},
};
Step-by-step fix
Step 1: Identify above-the-fold viewport content per page type
Open Chrome DevTools and set the viewport to 1300x900 (desktop) and 375x812 (mobile). Identify every element visible without scrolling: navigation, hero, headline, above-fold images, primary CTA. List the CSS class names, IDs, and element types. These selectors define the boundary of critical CSS. Repeat for each distinct page template on your site.
// Run in DevTools console to list visible elements at load
// Helps verify which selectors critical CSS tools should capture
const viewport = {
top: 0,
left: 0,
right: window.innerWidth,
bottom: window.innerHeight,
};
const visible = Array.from(document.querySelectorAll('*'))
.filter(el => {
const rect = el.getBoundingClientRect();
return (
rect.top < viewport.bottom &&
rect.bottom > viewport.top &&
rect.left < viewport.right &&
rect.right > viewport.left &&
rect.width > 0 &&
rect.height > 0
);
})
.map(el => el.className || el.tagName.toLowerCase());
console.log([...new Set(visible)].join('\n'));
Step 2: Choose an extraction tool appropriate to your stack
Match the tool to your rendering model. For static sites or SSR with known HTML at build time, use Beasties (fast, no headless browser). For SPAs or sites with client-rendered above-fold content, use Penthouse or Critical (accurate, uses headless Chrome). For framework projects, prefer the built-in solution: Next.js automatic extraction, Astro inline styles, Nuxt inlineStyles, or eleventy-critical-css.
import critical from 'critical';
// Generate and inline critical CSS for a static HTML file
await critical.generate({
base: 'dist/',
src: 'index.html',
target: 'index.html', // Overwrites with inlined styles
width: 1300,
height: 900,
inline: true, // Inline critical CSS into the HTML file
extract: true, // Remove critical rules from the async stylesheet
});
Step 3: Generate per-route critical CSS
Run extraction against each page template at representative viewport sizes. Do not use a single shared critical CSS file across all page types -- the per-route approach keeps each inline block small (under 10KB compressed) and accurate. Store outputs as versioned assets keyed by route pattern. Integrate extraction into your CI/CD pipeline so it runs on every deployment.
- name: Build site
run: npm run build
- name: Generate critical CSS
run: node scripts/generate-critical.mjs
env:
# Critical requires Chrome; use the pre-installed version
PUPPETEER_EXECUTABLE_PATH: /usr/bin/google-chrome-stable
- name: Verify critical CSS size
run: |
for f in critical/*.css; do
size=$(wc -c < "$f")
echo "$f: $size bytes"
# Warn if uncompressed exceeds 15KB
[ "$size" -gt 15360 ] && echo "WARNING: $f may be too large"
done
Step 4: Inline in the head and async-load the full stylesheet
Place the extracted CSS inside a <style> tag in <head>, before any <link> elements. Replace the synchronous <link rel="stylesheet"> with an async loading pattern. The most reliable pattern uses rel="preload" with an onload callback that swaps the rel to stylesheet. Always include a <noscript> fallback for the synchronous link.
media="print" on the link causes the browser to deprioritize the stylesheet, then the onload callback changes the media to all. Both approaches work. The rel=preload pattern is generally preferred because it explicitly signals high-priority network fetch while deferring parse-blocking.
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 1. Inline critical CSS (extracted per route) -->
<style>
/* Critical above-fold CSS: ~8KB compressed */
:root { --c-bg: #fff; --c-text: #0f172a; --c-accent: #22c55e; }
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; background: var(--c-bg); color: var(--c-text); }
.site-header { position: sticky; top: 0; display: flex; align-items: center; height: 60px; padding: 0 1.5rem; background: var(--c-bg); border-bottom: 1px solid #e2e8f0; z-index: 100; }
.hero { display: grid; place-items: center; min-height: 70vh; padding: 4rem 1.5rem; text-align: center; }
.hero__title { font-size: clamp(2rem, 5vw, 4rem); font-weight: 800; line-height: 1.1; margin: 0 0 1rem; }
.hero__sub { font-size: 1.25rem; color: #64748b; max-width: 40ch; margin: 0 auto 2rem; }
.btn-primary { display: inline-flex; align-items: center; padding: .75rem 1.75rem; background: var(--c-accent); color: #fff; border-radius: 6px; font-weight: 600; text-decoration: none; }
</style>
<!-- 2. Async-load full stylesheet -->
<link rel="preload" href="/styles/main.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles/main.css"></noscript>
</head>
Step 5: Validate with Lighthouse FCP/LCP and Chrome DevTools Coverage
Run Lighthouse in incognito mode against each page type and record FCP and LCP before and after. Open the Chrome DevTools Coverage panel (Shift+Cmd+P, type "Coverage", press Enter) and reload the page. The Coverage panel shows which CSS bytes are used during the initial render versus loaded but unused. Confirm that your inlined critical block shows near-100% coverage, and that below-fold rules are in the async stylesheet. Also check CLS has not regressed due to late-loading layout styles.
// lighthouserc.js -- validate critical CSS impact on FCP and LCP
module.exports = {
ci: {
collect: {
urls: [
'http://localhost:3000/',
'http://localhost:3000/blog/sample-post/',
'http://localhost:3000/shop/sample-product/',
],
numberOfRuns: 3,
},
assert: {
assertions: {
// FCP target: under 1.8s (Good threshold)
'first-contentful-paint': ['error', { maxNumericValue: 1800 }],
// LCP target: under 2.5s (Good threshold)
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
// Confirm no render-blocking stylesheets remain
'render-blocking-resources': ['warn', { maxLength: 0 }],
},
},
},
};
Pitfalls to avoid
-
Extracting too much CSS. If your inlined critical block exceeds 14KB compressed, it no longer fits in the first TCP congestion window and you lose the primary benefit. Tools with conservative extraction heuristics (or misconfigured viewport heights) often include below-fold content. Always measure compressed size and trim aggressively. See the performance budget tool for size tracking.
-
Missing CSS custom properties. Static extraction tools parse rules but may not follow
var()references. If your above-fold styles use CSS variables defined elsewhere, those variables must also appear in the critical block. Always include your:rootvariable declarations in extracted critical CSS. -
Broken pseudo-selectors and state classes. Tools that match CSS to static HTML miss rules for
:hover,:focus,:active, and JavaScript-toggled classes. If interaction styles are stripped from the critical block and the full stylesheet loads slowly, users see visual glitches on first interaction. Include interactive state rules for above-fold interactive elements manually if needed. -
Omitting @font-face declarations. If your above-fold content uses a custom font, the
@font-facerule and afont-display: swapdeclaration must appear in the critical block. Without them, the browser discovers the font only when the async stylesheet loads, delaying font download and potentially causing a flash of unstyled text. See the font loading CLS fix for the full font optimization approach. -
Dynamic class mismatches and CLS from late-loading stylesheets. When a JavaScript framework adds classes after hydration (animation classes, theme classes, personalization classes), those classes may not appear in the static HTML that extraction tools analyze. The styles for those classes end up only in the async stylesheet. When the stylesheet loads, those elements reflow -- causing CLS. Either include those styles in the critical block manually, or restructure the code to avoid layout-affecting class additions after first paint.
Quick checklist
- Critical CSS is extracted per route, not as a single shared file
- Inlined CSS block is under 10KB compressed
-
CSS custom properties (
:rootvariables) are included in the critical block -
@font-facedeclarations for above-fold fonts are in the critical block -
Full stylesheet loads asynchronously via
rel="preload"+onloadswap -
<noscript>fallback includes synchronous stylesheet link - Lighthouse FCP and LCP scores verified against Good thresholds after deployment
- CLS score checked after optimization to confirm no layout regressions
- Extraction integrated into CI/CD pipeline and runs on every deployment
Frequently asked questions
Build time is strongly preferred for static and server-rendered sites. Build-time extraction runs once per deployment, adds no latency to user requests, and produces consistent output that can be cached indefinitely. Runtime extraction -- generating critical CSS on the fly per request -- is only appropriate for sites with highly dynamic, user-specific above-the-fold content. Runtime extraction adds 50-200ms per uncached request and requires careful cache invalidation strategy. For most teams, build-time extraction with per-route variants covers the full range of templates without runtime overhead.
Regenerate whenever above-the-fold layout changes: new hero components, font updates, header redesigns, or breakpoint adjustments. In practice, tie regeneration to your CI pipeline so it runs automatically on every deployment. If your content management system controls hero images or layouts, consider nightly regeneration or triggering on content publish events. Stale critical CSS is not catastrophic -- the full stylesheet still loads asynchronously -- but it may include unnecessary rules that bloat the inline block beyond the 14KB target.
Yes, but the approach differs by library. Styled-components and Emotion support server-side style collection: wrap your component tree with the library's ServerStyleSheet or cache provider during SSR, then flush collected styles into the document head. This is conceptually identical to critical CSS extraction -- you are inlining only the styles needed for the server-rendered HTML. Libraries like vanilla-extract and Linaria compile to static CSS at build time, making them compatible with standard tools like Beasties and Critters without any additional configuration.
TCP slow start limits the first congestion window to approximately 14KB of data (10 TCP segments of 1460 bytes each, before ACKs begin arriving). Resources that fit within this window are received in a single round-trip. If your HTML document plus inlined critical CSS fits within 14KB compressed, the browser can begin painting without any additional network round-trips after the initial connection. Exceeding 14KB forces at least one extra round-trip, adding one full network latency to FCP. Keep inlined critical CSS under 10KB compressed, leaving room for the HTML itself.
Yes, if done incorrectly. The two most common causes of critical-CSS-induced CLS are: (1) omitting layout rules for elements that shift into view during scroll, because the full stylesheet loads asynchronously and may arrive after the browser has calculated layout; and (2) missing @font-face declarations in the critical block, causing fonts to load later and trigger text reflow. To avoid CLS, include all layout-affecting rules for any element that could enter the viewport during initial load, and always include @font-face with font-display: swap in the inlined block. After optimization, always re-run a full Lighthouse audit to confirm CLS has not regressed.
Sara Kim
Performance Engineer
Sara specializes in Core Web Vitals optimization across large-scale production sites. She focuses on rendering pipeline analysis, critical resource identification, and build-tooling integration for performance-first engineering teams.
Related fixes
Fix Render-Blocking CSS for LCP
Eliminate blocking stylesheets that delay first paint and hurt LCP scores on every page load.
FixFix CLS Caused by Font Loading
Eliminate Cumulative Layout Shift from web font swaps using font-display, size-adjust, and preloading.
FixFix INP with JavaScript Bundle Optimization
Reduce Interaction to Next Paint by code-splitting, deferring non-critical scripts, and trimming bundle size.