Skip to content

Critical CSS

advanced19 min read

Why CSS Blocks Rendering

Here's something that surprises a lot of developers: the browser will not paint a single pixel until the CSSOM (CSS Object Model) is fully constructed. Every external CSS file referenced in <head> is render-blocking — the browser must download, parse, and process the entire stylesheet before anything shows up.

On a fast connection, you'd never notice. But on a 3G connection where a 100KB stylesheet takes 700ms to download? Your user stares at a white screen for nearly a second — even though the HTML and all its content arrived hundreds of milliseconds earlier. The content is there. The browser just refuses to show it.

Mental Model

Imagine a theater performance. The HTML is the script — it arrives first and the actors (DOM nodes) are ready. The CSS is the lighting design. The director (browser) refuses to raise the curtain until every light is positioned perfectly. If the lighting plan is delivered late, the audience waits in the dark — even though the actors are already on stage. Critical CSS is like giving the lighting crew the instructions for Act 1 in advance so the curtain rises immediately, while the rest of the lighting plan arrives during the performance.

What Critical CSS Is

The fix is elegantly simple. Critical CSS is the minimal set of styles needed to render the above-the-fold content — everything the user sees without scrolling. You extract those styles and inline them directly into the HTML document's <style> tag.

<head>
  <!-- Critical CSS inlined — renders immediately, no HTTP request -->
  <style>
    *,*::before,*::after{box-sizing:border-box}
    body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:#fff}
    .header{display:flex;align-items:center;padding:1rem 2rem;border-bottom:1px solid #eee}
    .hero{padding:4rem 2rem;text-align:center}
    .hero h1{font-size:3rem;font-weight:700;letter-spacing:-0.02em}
    .hero p{font-size:1.25rem;color:#666;max-width:40rem;margin:1rem auto}
  </style>
  
  <!-- Full stylesheet loaded asynchronously — doesn't block rendering -->
  <link rel="preload" href="/styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
  <noscript><link rel="stylesheet" href="/styles.css"></noscript>
</head>

The browser receives the HTML, finds the inlined critical CSS, builds the CSSOM for above-the-fold content immediately, and paints — all from the initial HTML response, with zero additional network requests. The full stylesheet loads in the background and applies when ready, styling below-the-fold content.

Quiz
A page has a 120KB external CSS file. First Contentful Paint takes 1.4s on 4G. You inline 8KB of critical CSS and async-load the rest. What happens to FCP?

Extracting Critical CSS

So how do you figure out which styles are "critical"? You've got a few options.

Critters is a Webpack plugin that does the hard work for you. It renders your page at build time, determines which CSS rules apply to above-the-fold elements, and inlines them.

// next.config.js with critters
const withCritters = require('critters-webpack-plugin');

module.exports = {
  webpack(config) {
    config.plugins.push(new withCritters({
      preload: 'swap',       // Preload strategy for non-critical CSS
      inlineFonts: false,     // Don't inline font files
      pruneSource: false,     // Keep the full stylesheet for below-fold
    }));
    return config;
  },
};

Automated: critical npm Package

For non-framework builds, the critical package uses Puppeteer to render the page and extract above-the-fold styles:

const critical = require('critical');

await critical.generate({
  base: 'dist/',
  src: 'index.html',
  target: 'index-critical.html',
  inline: true,
  width: 1300,   // Viewport width
  height: 900,   // Viewport height (defines "above the fold")
  dimensions: [
    { width: 375, height: 667 },  // Mobile
    { width: 1300, height: 900 }, // Desktop
  ],
});

It generates critical CSS for multiple viewport sizes, covering both mobile and desktop above-the-fold content.

Manual Extraction

For surgical control, identify critical CSS manually:

  1. Open DevTools → Coverage tab
  2. Reload the page
  3. The Coverage tab shows which CSS rules were used during initial render
  4. Extract those rules into an inline <style> block

This is tedious but gives perfect precision. Use for landing pages where every byte matters.

Quiz
Critters extracts critical CSS at build time by rendering the page. What's the main limitation of this approach?

Deferring Non-Critical CSS

Once you've inlined the critical stuff, the full stylesheet still needs to load — just not in a render-blocking way. Here's the standard pattern:

<!-- The preload/onload trick -->
<link rel="preload" href="/styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>

How this works:

  1. rel="preload" fetches the CSS at high priority without blocking render
  2. When the CSS finishes loading, onload fires and changes rel to "stylesheet", which applies the styles
  3. this.onload=null prevents the handler from firing twice in some browsers
  4. <noscript> fallback loads the CSS normally for users without JavaScript

Alternative approach using media query trick:

<!-- media="print" makes it non-render-blocking, onload applies it -->
<link rel="stylesheet" href="/styles.css" media="print" onload="this.media='all'">
Avoiding FOUC (Flash of Unstyled Content)

When critical CSS renders the above-the-fold content and the full stylesheet loads later, there's a risk of FOUC — the moment when below-the-fold styles haven't loaded yet and the user scrolls down to see unstyled content.

Mitigations:

  1. Be generous with critical CSS: Include styles for content slightly below the fold (first scroll's worth)
  2. Preload the full stylesheet: rel="preload" starts the download immediately — it usually finishes before the user scrolls
  3. Font matching: Ensure critical CSS includes fallback font metrics that match the web font to prevent layout shift when fonts load

In practice, FOUC from deferred CSS is rare because the full stylesheet typically loads within 200-500ms — well before the average user scrolls.

Font Loading Strategies

Fonts are their own headache. By default, text is invisible until the custom font loads (Flash of Invisible Text — FOIT) or renders with a fallback font that shifts when the custom font arrives (Flash of Unstyled Text — FOUT). Neither is great.

font-display property

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;    /* Show fallback immediately, swap when loaded */
  /* font-display: optional; — show fallback, only swap if loaded very fast */
}
ValueBehaviorBest For
swapShow fallback immediately, swap when font loadsBody text — content visible ASAP
optionalShow fallback, swap only if font loads within ~100msWhen layout shift from font swap is worse than using fallback
fallbackShort invisible period (~100ms), then fallback, swap if loaded within 3sBalanced approach
blockInvisible for up to 3s, then fallbackIcon fonts (invisible is better than wrong glyph)

Preloading critical fonts

<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>

This starts the font download in parallel with CSS parsing, typically making it available before the browser encounters the @font-face rule. Combined with font-display: swap, text is visible immediately with a system font fallback, and the custom font swaps in seamlessly.

Quiz
A page uses font-display: swap for its heading font. The font takes 800ms to load. What does the user see?

Reducing font CLS with size-adjust

/* System font fallback sized to match the custom font's metrics */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  ascent-override: 90.49%;
  descent-override: 22.56%;
  line-gap-override: 0%;
  size-adjust: 107.06%;
}

body {
  font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
}

The size-adjust and metric overrides ensure the fallback font takes up the same space as the custom font. When the swap happens, there's zero layout shift. Next.js's next/font generates these adjustments automatically.

CSS in Next.js: Critical Path Behavior

Let's look at how all of this plays out in Next.js specifically.

CSS Modules

Next.js extracts CSS Modules into external stylesheets during the build. Per-route CSS is loaded only for the active route, but — and this is the nuance — it's still render-blocking for that route.

// dashboard.module.css is only loaded when /dashboard is visited
// But it blocks rendering of the dashboard page
import styles from './dashboard.module.css';

Tailwind CSS

Tailwind generates a single CSS file with only the classes used in your project (thanks to its JIT compiler). This file is typically 10-30KB compressed for a well-built application — small enough that the render-blocking impact is minimal.

For even better performance, Tailwind classes used in critical components can be inlined via build-time extraction, while the full stylesheet loads asynchronously.

Global CSS

// layout.tsx
import './globals.css';  // Render-blocking for the entire app

Global CSS in Next.js is loaded on every page. Keep it minimal — reset, typography, CSS custom properties. Component-specific styles belong in CSS Modules or Tailwind utilities.

Quiz
Your Next.js app has a 45KB global CSS file loaded in the root layout. Every page waits for it before painting. What's the best optimization?

Measuring Render-Blocking CSS Impact

Time to put numbers on the problem.

DevTools Coverage Tab

  1. Open DevTools → Sources → Coverage (Ctrl+Shift+P → "Show Coverage")
  2. Reload the page
  3. Check the CSS files — the red bars show unused CSS bytes at initial render
  4. The percentage of unused CSS tells you how much is non-critical

Typical finding: 70-90% of CSS is unused on initial render. That's 70-90% of render-blocking time that could be eliminated by inlining only the critical 10-30%.

Performance Panel

  1. Open DevTools → Performance → Record a reload
  2. Look at the "Render-Blocking Resources" in the Opportunities section
  3. Note the time between HTML received and First Paint — this is the CSS blocking window

Lighthouse

Lighthouse reports "Eliminate render-blocking resources" with specific savings estimates:

Eliminate render-blocking resources — Potential savings of 820ms
  /styles.css (120KB, ~700ms)
  /fonts.css (15KB, ~120ms)
HTTP/2 and critical CSS

With HTTP/2 server push (now deprecated in favor of 103 Early Hints), the server could push the CSS file alongside the HTML. With Early Hints, the browser starts fetching CSS before the HTML response is complete. These reduce the CSS blocking window but don't eliminate it — the browser still must parse the full CSS before painting. Critical CSS inlining remains the gold standard because it eliminates the blocking request entirely.

Key Rules
  1. 1CSS is render-blocking. The browser paints nothing until the CSSOM is fully constructed from all linked stylesheets.
  2. 2Critical CSS = above-the-fold styles inlined in HTML. Eliminates the render-blocking CSS request for initial paint.
  3. 3Defer non-critical CSS with rel='preload' + onload swap or the media='print' trick.
  4. 4Use font-display: swap for body text (visible immediately) and preload critical font files. Use next/font for automatic metric adjustments to prevent CLS.
  5. 5Automated extraction (Critters, critical package) determines above-fold styles by rendering at configured viewport sizes. Use multiple dimensions for accuracy.
  6. 6Typical finding: 70-90% of CSS is unused on initial render. That's all non-critical, deferrable CSS.
  7. 7Keep global CSS minimal — only reset, typography, and custom properties. Component styles belong in CSS Modules or Tailwind utilities.
Interview Question

Q: Your Next.js app has FCP of 2.8s on fast 3G. DevTools shows a 95KB render-blocking stylesheet. Walk me through optimizing this.

A strong answer: First, measure: open Coverage tab to see how much of the 95KB is used at initial render (typically 10-20%). Extract that critical CSS and inline it in the document head. Defer the full 95KB stylesheet with rel="preload" + onload swap. For fonts: preload the primary font file with rel="preload" as="font" and use font-display: swap to show text immediately in a fallback font. Use next/font for automatic font metric adjustments to prevent CLS during the font swap. Audit the 95KB: is all of it needed? Remove unused CSS with PurgeCSS or switch to Tailwind (JIT generates only used classes — typically 10-30KB). Target: FCP under 1.5s by eliminating the render-blocking round trip.