MODRACXKENNETH D'SILVA

← Archive & Insights

Critical CSS Optimization & Above-The-Fold Rendering

A critical CSS build extracted checkout in its empty-cart state, so the payment section had no styles for 900ms. Conversion dropped 18%. Here is how to extract it properly, and when not to bother.

By Kenneth D'SilvaReading Time: 26 min readCategory: Performance & Speed

1. The Checkout That Rendered Without a Submit Button

On a Thursday in March a client's checkout conversion dropped by 18% overnight. No deploy had gone out that day. The deploy had gone out on Wednesday, and it had passed every test.

What had shipped was a critical CSS build. The extraction tool had rendered each template in a headless Chrome at 1300×800, collected the CSS rules that applied to elements in that viewport, inlined them into the head, and deferred the rest. On checkout it had rendered the page in the state the crawler saw it: an empty cart. An empty cart shows a "your basket is empty" panel and no payment section, so none of the payment section's CSS was classified as critical. The deferred stylesheet still loaded and still fixed everything — eventually. But the deferred load was behind a third-party script that a tag manager had started loading synchronously two weeks earlier, and for about 900 milliseconds on a mobile connection the submit button was an unstyled inline element sitting inside an unstyled container, roughly where the address form should have been.

People tapped it. Some of them tapped it twice. Some of them scrolled past it because it did not look like a button. The 18% did not come from a broken page; it came from a page that was visually wrong for just under a second at the exact moment somebody was deciding whether to give us their card details.

I have implemented critical CSS on maybe a dozen storefronts. It works. It is also the optimisation I have seen abandoned more often than any other, and almost never because it stopped delivering — because it stopped being maintained, and the failure mode of unmaintained critical CSS is worse than not having it. This article is about how to do it, and equally about whether you should.

2. What the Browser Is Actually Waiting For

The mechanism is simple enough that it is worth stating precisely, because a lot of confused advice comes from getting it slightly wrong.

The browser cannot paint until it has a render tree, and the render tree is the DOM combined with the CSSOM. It cannot build the CSSOM until it has parsed every stylesheet that applies to the current media. So an external stylesheet in the head blocks first paint by the full cost of discovering it, connecting if it is on a new origin, downloading it, and parsing it.

This is not the same thing as blocking parsing — the HTML parser carries on building the DOM behind the scenes. It is blocking paint, which is what the user experiences. And it is total, not incremental: the browser does not paint what it can and refine later. One stylesheet, one byte outstanding, nothing on screen.

The general question of what blocks rendering and how to unblock it is a broader topic and I have written about removing render-blocking resources separately. What is specific to CSS, and what makes critical CSS a distinct technique rather than a special case of deferring things, is that you cannot simply defer the stylesheet. Defer all of it and the page paints unstyled, which is worse. The whole idea rests on splitting one indivisible blocking resource into a small part you inline and a large part you can afford to wait for.

The numbers that make it worth doing

A typical Magento 2 theme ships somewhere between 250KB and 700KB of CSS uncompressed, 40KB to 120KB after Brotli. A Shopify theme built on a framework will often be similar. A Tailwind build with proper purging can be under 15KB, which is one of the more compelling arguments for it.

On a good connection none of this is a problem. The cost appears on mobile, on a first visit, with an empty cache. Take a 90KB compressed stylesheet on a connection with 150ms round-trip time: DNS and connection if it is on a separate host, then request, then a response that spans several congestion-window round trips because 90KB does not fit in the initial window. Four hundred to eight hundred milliseconds before a single pixel appears, on top of whatever your time to first byte was.

Inlining 12KB of critical CSS in the initial HTML response removes all of that. The critical CSS arrives in the same packets as the document. First paint happens as soon as the HTML has been parsed far enough to have content, which on a well-built page is a few hundred milliseconds after the first byte.

On projects where I measured it properly, first contentful paint improved by 500ms to 1.2s on a throttled mobile profile. Largest contentful paint improved by less — usually 200 to 500ms — because LCP is normally an image or a heading whose arrival depends on other things too. If your LCP element is text, critical CSS moves it a lot. If it is a hero image, it moves it only as much as the paint gate was the binding constraint.

3. What "Critical" Actually Means

The definition everyone uses is "the CSS needed to render the above-the-fold content". That definition contains three ambiguities, and every implementation problem I have hit traces back to one of them.

Above the fold at which viewport? 360×640 is a common small Android. 390×844 is a modern iPhone. 1440×900 is a laptop. 2560×1440 is a desktop monitor where the fold is a long way down and a great deal more CSS is "critical". Extract at the small viewport and desktop users see a flash of unstyled lower content. Extract at the large one and your inlined CSS is 40KB, which defeats the purpose.

What I do: extract at several viewports and union the results, but weight the decision toward mobile by capping the desktop height contribution. In practice I use 360×720, 390×844, and 1280×800, and I do not include anything below 800px of the desktop viewport. The union is bigger than any single extraction but smaller than a full desktop extraction, and it fails gracefully in both directions.

In which page state? This is what broke the checkout. A page has states: logged in or out, cart empty or full, a promotional banner that shows for the first ten days of a campaign, a cookie consent overlay, a validation error on a form, a variant selector with the second option chosen. The crawler sees one state. Users are in all of them.

What counts as "needed"? A rule that sets a hover colour is not needed for first paint. A rule that sets a font-family is, because without it the text renders in Times New Roman and then reflows. A rule inside a media query that does not match the extraction viewport is not needed there but is needed on a different device. Keyframes referenced by an animation on a visible element are needed; keyframes referenced by nothing visible are not, and most extractors keep all of them because tracing the reference is hard.

4. Extraction Approaches, and Which I Trust

Three families, with genuinely different reliability.

Static analysis. Parse the HTML, parse the CSS, match selectors against the DOM as written. Fast, no browser needed, and wrong whenever the DOM is built or mutated by JavaScript — which on a modern storefront is most of the interesting parts. I do not use this alone.

Coverage API. Chrome's DevTools protocol can report which CSS rules were actually used during a page load. This sounds like exactly what you want and is subtly not: coverage reports what was used at any point during the recording, including below the fold and including rules triggered by scroll or interaction. It also reports usage at byte-range granularity, which makes reconstructing valid CSS fiddly. Useful for auditing how much of your stylesheet is dead, which is a genuinely valuable exercise, but not the right tool for extraction.

Headless render plus geometry check. Load the page in a real browser, walk every element, ask whether its bounding box intersects the viewport, and collect the rules that match those elements. This is what the good tools do and it is what I use. It handles JavaScript-built DOM, it handles computed styles, and it is only as wrong as your ability to put the page into the right state.

The last point is the whole game. The extractor is not the hard part; getting the page into every state that matters is.

5. Writing the Extractor

Off-the-shelf tools are fine and I have shipped several. But writing a minimal one clarifies what the off-the-shelf tools are doing and, more importantly, gives you the hook for state setup that most of them handle poorly.

// extract-critical.mjs — headless extraction with explicit state setup.
// The setup callback is the part that matters; everything else is mechanical.
import puppeteer from 'puppeteer';

const VIEWPORTS = [
  { width: 360, height: 720, isMobile: true },
  { width: 390, height: 844, isMobile: true },
  { width: 1280, height: 800, isMobile: false },
];

export async function extract(url, { setup = async () => {} } = {}) {
  const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
  const rules = new Set();

  for (const viewport of VIEWPORTS) {
    const page = await browser.newPage();
    await page.setViewport(viewport);
    await page.goto(url, { waitUntil: 'networkidle0' });
    await setup(page);                       // put the page into the state we care about
    await page.evaluate(() => document.fonts.ready);

    const found = await page.evaluate((height) => {
      const out = [];
      const inViewport = (el) => {
        const r = el.getBoundingClientRect();
        // A zero-height element can still matter (a collapsed container that
        // holds a visible child), so only exclude things clearly off-screen.
        return r.top < height && r.bottom > -1 && r.right > -1;
      };

      const walk = (rule, mediaText) => {
        if (rule.type === CSSRule.MEDIA_RULE) {
          // Keep media queries whose condition could apply on ANY device we
          // support, not just the one we are currently emulating.
          for (const inner of rule.cssRules) walk(inner, rule.conditionText);
          return;
        }
        if (rule.type === CSSRule.KEYFRAMES_RULE) return;   // handled separately
        if (rule.type === CSSRule.FONT_FACE_RULE) { out.push([null, rule.cssText]); return; }
        if (rule.type !== CSSRule.STYLE_RULE) return;

        // Strip pseudo-states that cannot apply at first paint
        const testable = rule.selectorText
          .split(',')
          .map(s => s.replace(/:(hover|focus|active|focus-visible|focus-within)\b/g, '').trim())
          .filter(Boolean);

        for (const sel of testable) {
          let matches;
          try { matches = document.querySelectorAll(sel); } catch { continue; }
          for (const el of matches) {
            if (inViewport(el)) { out.push([mediaText, rule.cssText]); return; }
          }
        }
      };

      for (const sheet of document.styleSheets) {
        let cssRules;
        try { cssRules = sheet.cssRules; } catch { continue; }  // cross-origin
        for (const rule of cssRules) walk(rule, null);
      }
      return out;
    }, viewport.height);

    for (const [media, text] of found) {
      rules.add(media ? `@media ${media}{${text}}` : text);
    }
    await page.close();
  }

  await browser.close();
  return [...rules].join('\n');
}

Two decisions in there deserve defending. Stripping hover and focus states removes rules that cannot possibly affect first paint and typically cuts 15–20% off the output; the deferred stylesheet arrives long before anyone has hovered anything. And keeping @font-face unconditionally matters because a missing font declaration in the critical CSS means the text paints in a fallback and then reflows, which is exactly the layout shift you were trying to avoid.

The setup callback is where you fix the checkout bug from the opening. Every state that changes above-the-fold layout needs an entry.

// The state matrix. Each entry is a page you extract separately and union.
// This list is the actual deliverable; the extractor is boilerplate.
export const STATES = [
  { name: 'home',            url: '/',                       setup: dismissConsent },
  { name: 'home-banner',     url: '/?preview_banner=1',      setup: dismissConsent },
  { name: 'plp',             url: '/collections/sofas',      setup: dismissConsent },
  { name: 'pdp',             url: '/products/aldwych-sofa',  setup: dismissConsent },
  { name: 'pdp-oos',         url: '/products/discontinued',  setup: dismissConsent },
  { name: 'cart-empty',      url: '/cart',                   setup: dismissConsent },
  { name: 'cart-full',       url: '/cart',                   setup: async (p) => { await seedCart(p); await dismissConsent(p); } },
  { name: 'checkout',        url: '/checkout',               setup: async (p) => { await seedCart(p); await dismissConsent(p); } },
  { name: 'checkout-error',  url: '/checkout',               setup: async (p) => { await seedCart(p); await submitEmpty(p); } },
  { name: 'consent-shown',   url: '/',                       setup: async () => {} },   // banner visible
  { name: 'account',         url: '/account',                setup: logIn },
];

That list is not exhaustive for any real site and it is not meant to be. The point is that it exists, is version-controlled, and gets a new row every time someone reports a flash of unstyled content. That review discipline is the difference between critical CSS that survives and critical CSS that gets ripped out in eighteen months.

6. Deduplicating and Budgeting the Output

Union eleven states and you get a lot of duplicated rules, plus a payload that has quietly grown to 45KB. Both need handling.

Deduplication is mostly a matter of running the union through a CSS parser that merges identical declarations and collapses selectors — postcss with cssnano in a conservative preset does the job without the risky transformations. Do not let it merge rules across media query boundaries or reorder declarations; specificity in CSS is order-dependent and an aggressive minifier will happily produce output that is smaller and wrong.

The budget is the more important constraint. My rule of thumb: the inlined critical CSS plus the rest of the HTML head should fit comfortably inside the initial congestion window, which after the standard increase to 10 segments is roughly 14KB of compressed payload. Beyond that you are paying an extra round trip for the HTML itself, which partially cancels the benefit you were buying.

In practice I aim for under 14KB of critical CSS compressed, which is typically 50–60KB raw, and I treat anything over 20KB compressed as a signal that the extraction is wrong rather than that the site is complicated. When I hit that, the cause has almost always been one of: a rule matching a huge number of elements pulling in a whole framework grid; a below-the-fold component that happens to have a 1px sliver in the viewport; or an extractor keeping every @keyframes block in the stylesheet.

// Fail the build if critical CSS outgrows the initial congestion window.
// A silent 45KB inline is worse than no critical CSS at all.
import { brotliCompressSync } from 'node:zlib';

const LIMIT = 14 * 1024;
const compressed = brotliCompressSync(Buffer.from(criticalCss)).length;

if (compressed > LIMIT) {
  throw new Error(
    `critical CSS is ${compressed}B brotli (limit ${LIMIT}B). ` +
    `Check for below-fold components with slivers in the viewport, ` +
    `and for @keyframes being retained wholesale.`
  );
}

7. Loading the Rest Without Blocking

Once the critical CSS is inline, the full stylesheet must load without blocking paint. There is one pattern that works everywhere and several that mostly work.

<!-- The full stylesheet, non-blocking. media="print" means the browser
     downloads it at low priority and does not block render on it; the onload
     handler flips it to "all" once it has arrived. The noscript copy covers
     the small population with JS disabled. -->
<link rel="stylesheet" href="/assets/main.css"
      media="print" onload="this.media='all';this.onload=null" />
<noscript><link rel="stylesheet" href="/assets/main.css" /></noscript>

The alternative that reads better and works less well:

<!-- rel="preload" as="style" fetches at HIGH priority and does not apply the
     styles until onload swaps the rel. Higher priority is usually the wrong
     trade here: it competes with your LCP image for bandwidth. -->
<link rel="preload" href="/assets/main.css" as="style"
      onload="this.rel='stylesheet'" />

I use the media="print" form by default. The preload form pulls the stylesheet at high priority, which means it competes with the hero image and the fonts for the same bandwidth, and the whole premise of critical CSS is that the full stylesheet is not urgent. If it were urgent you would not be deferring it.

Both patterns have the same latent bug: if the JavaScript inline handler is stripped by a Content Security Policy that forbids inline event handlers, the stylesheet never applies. This is not hypothetical — I have seen a CSP rollout silently break a site's non-critical styles for every browser, discovered a week later. If you run a strict CSP, either allow these specific handlers via a hash, or use the JavaScript-free variant:

<!-- No inline handler, so it survives a strict CSP. The stylesheet is
     discovered by the preload scanner, downloaded, and applied when the
     parser reaches the real link at the end of body. -->
<link rel="preload" href="/assets/main.css" as="style" />
<!-- ...page content... -->
<link rel="stylesheet" href="/assets/main.css" />

That works because a stylesheet link at the end of the body still blocks paint of content after it, of which there is none, while the preload has already warmed the cache. It is slightly less clean and considerably more robust.

8. The Flash Problem

Critical CSS creates a window where the page is styled by the critical subset only. Everything you got wrong is visible in that window, and the window is longest for exactly the users you were trying to help.

Three distinct symptoms, with different causes.

Flash of unstyled content below the fold. The user scrolls faster than the stylesheet arrives and sees raw markup. Fixable by extending your extraction viewport height — I use 1.3× the real height as the intersection threshold, which costs a little payload and buys a lot of margin.

Layout shift when the full stylesheet applies. The critical CSS positioned something approximately and the full stylesheet corrects it. This is a Cumulative Layout Shift regression caused by a performance optimisation, which is a particularly annoying thing to explain in a meeting. The cause is nearly always a rule that was excluded because the element it targets was not matched at extraction time — a class added by JavaScript, a state the crawler was not in.

Fonts. If @font-face is not in the critical CSS, text paints in the fallback and reflows when the real font applies. Always include font declarations, and pair them with font-display: swap plus metric-adjusted fallbacks so the swap itself does not shift.

The measurement that catches all three is a CLS observer scoped to the period before the deferred stylesheet lands.

// Attribute layout shifts to the critical-CSS window specifically.
// A shift at t=850ms that stops the moment the deferred sheet applies is
// diagnostic: your extraction missed something that element needed.
let deferredAppliedAt = null;
const sheet = document.querySelector('link[media="print"]');
sheet?.addEventListener('load', () => { deferredAppliedAt = performance.now(); });

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue;
    const phase = deferredAppliedAt === null ? 'pre-deferred-css' : 'post';
    if (phase !== 'pre-deferred-css') continue;
    const sources = entry.sources?.map(s => ({
      node: s.node?.tagName + '.' + (s.node?.className || ''),
      from: s.previousRect, to: s.currentRect,
    }));
    navigator.sendBeacon('/rum/cls-critical', JSON.stringify({
      value: entry.value, at: Math.round(entry.startTime), sources,
    }));
  }
}).observe({ type: 'layout-shift', buffered: true });

Run that in production for a week and you will get a list of the exact elements your extraction missed, ranked by how much damage they do. It is far more useful than any amount of manual QA, because it samples every state real users are actually in — including the ones nobody put in the state matrix.

9. The Caching Argument, Which Is Weaker Than It Sounds

The standard objection: inlined CSS cannot be cached separately, so returning visitors download it again inside every HTML response.

True, and mostly not important. Some arithmetic. A 12KB compressed inline block on a site where a returning visitor views six pages costs 72KB of redundant transfer across the session. The full stylesheet they would otherwise have downloaded once is 90KB. So for a two-page session inlining is roughly break-even, for a six-page session it costs you, and for a one-page session — which on a storefront arriving from paid social is the majority — it wins outright.

Then weight it. First-time visitors have empty caches and are the population whose experience determines whether they become customers. Returning visitors have the full stylesheet cached and are not blocked on it anyway. The redundant inline bytes cost them a little bandwidth and no waiting, because the bytes arrive in the same response they were already waiting for. Bandwidth on a warm connection is cheap; latency on a cold one is not.

If you want to be clever there is a cookie-based approach: inline critical CSS only when a cookie indicating "you have the stylesheet cached" is absent, set the cookie once the deferred sheet loads, and serve subsequent responses with a plain link. I have implemented this twice. Both times it worked and both times it made the page cacheability story worse, because the HTML now varies by cookie and your CDN either has to vary on it — fragmenting the cache — or do the decision at the edge. On a site with full-page caching, which is most Magento installs, this is a real cost. I would only do it if HTML caching is already personalised for other reasons.

10. The Maintenance Problem

Here is the part that actually determines whether critical CSS survives at your company, and it is not technical.

Critical CSS is a build artefact derived from the rendered output of your site. It goes stale whenever the site changes. A designer adds a promotional strip to the header; the critical CSS does not contain its styles; the strip renders unstyled for 700ms. Nobody notices in review because review happens on a fast connection where the window is 40ms.

So the extraction has to run in the build. Which means the build now needs a running instance of the site to crawl, which means either a preview deployment that is up before the CSS is built — a chicken-and-egg problem if the CSS is part of the build — or a two-stage deploy, or a headless render of the templates against a local server with fixtures.

Every one of those options is fiddly, and the fiddliness is where the technique dies. The sequence I have watched happen at three different companies is identical:

Someone implements critical CSS. It works, the numbers improve, everyone is pleased. Six weeks later the extraction step starts failing intermittently because the crawler times out on a slow staging environment. Someone adds a retry. Three months later it fails consistently after a Puppeteer upgrade. Someone adds continue-on-error to the CI step, meaning the build now silently ships the last successfully generated critical CSS. Nine months later that CSS describes a version of the site that no longer exists, and pages are flashing unstyled content on every load. Somebody investigates a CLS regression, finds it, and removes critical CSS entirely — and the numbers get better, because stale critical CSS is worse than none.

I have been the person who added continue-on-error. It seemed obviously correct at the time; a CSS optimisation should not block a release.

What actually prevents this:

Fail the build, loudly, when extraction fails. The temptation to make it non-blocking is exactly the failure mode. If it cannot run, that is a broken build, because shipping stale critical CSS is shipping a visual bug.

Stamp the artefact and check its age. Write the git SHA and timestamp into the generated file as a comment, and have the build refuse to use an artefact older than the last change to any CSS or template file. Staleness should be detectable rather than inferred from a CLS chart nine months later.

Assert on content, not just on success. A smoke test that the generated CSS contains selectors you know must be there — the header, the primary button, the product title — catches an extraction that ran successfully against a 500 page.

// Post-extraction assertions. An extraction that "succeeded" against an
// error page produces valid, tiny, useless CSS. These catch it.
const REQUIRED = [
  '.site-header', '.btn--primary', '.product-title',
  '.price', '@font-face', '.cart-summary',
];

const missing = REQUIRED.filter(s => !criticalCss.includes(s));
if (missing.length) {
  throw new Error(`critical CSS missing required selectors: ${missing.join(', ')}`);
}
if (criticalCss.length < 4000) {
  throw new Error(`critical CSS suspiciously small (${criticalCss.length}B) — ` +
                  `extraction probably hit an error page or a redirect`);
}

Assign an owner. Not a team, a person, and put the review of the state matrix into whatever quarterly process you already have. Unowned build steps rot.

If you cannot commit to all four, my honest advice is not to implement critical CSS. A stale implementation is a net negative, and "we will keep it updated" is a promise that survives about two staff changes.

11. Pages That Do Not Have One Shape

Static templates are the easy case. Real storefronts have several sources of variability that break the one-artefact-per-template model.

Logged-in state. The header changes, a wishlist count appears, prices may change for trade customers. If your HTML is full-page cached and personalised via a later fetch — the usual Magento pattern — then the page paints in the logged-out shape first anyway, and extracting logged-out is correct. If your HTML is rendered per-user, you need both.

A/B tests. A test that changes above-the-fold layout invalidates the critical CSS for the variant. The usual outcome is that the variant flashes and the test measures the flash rather than the design. My rule: if a test changes above-the-fold markup, it either ships with its own critical CSS or it does not ship. Experiment platforms that mutate the DOM after load are particularly bad here because they cause a visible shift regardless.

Consent banners. The banner is above the fold by definition and appears for a large share of first-time EU visitors — exactly the cold-cache population critical CSS targets. Extract with the banner shown, and inline its styles unconditionally. It is a few hundred bytes.

Merchandising slots. A homepage where the top block is chosen by a CMS and can be one of eight component types. The honest answer is to inline the CSS for all eight, or to constrain the CMS to a smaller set for the top slot. I have done the latter and the merchandising team was less upset than I expected once they saw the numbers.

Platform notes

On Magento 2, the natural place to inject is a layout XML block in the head with a template that reads the generated file for the current page type, keyed on the full action name. Magento's built-in CSS critical path feature exists and I have not had good results with it; it is coarse and hard to inspect. The bigger Magento-specific issue is that the default themes ship an enormous amount of CSS with deep specificity, and extraction produces a large critical payload because so many rules are entangled. Hyvä sidesteps this entirely by shipping a Tailwind build small enough that the whole stylesheet is under the size where critical CSS makes sense — which is, I think, the more interesting lesson.

On Shopify, you inline in theme.liquid and branch on template.name. The awkwardness is that app-injected CSS arrives through script tags you do not control, and those apps' styles are not in your stylesheet and therefore not in your critical CSS. An app that renders a badge above the fold will flash regardless of what you do. The only real mitigation is to reserve space for it with your own CSS so at least it does not shift.

{%- comment -%}
  Per-template critical CSS, with a single fallback. The `capture` avoids a
  second file read when the template-specific snippet does not exist.
{%- endcomment -%}
<style>
  {%- capture critical -%}
    {%- render 'critical-' | append: template.name -%}
  {%- endcapture -%}
  {%- if critical != blank -%}{{ critical }}{%- else -%}{%- render 'critical-default' -%}{%- endif -%}
</style>
<link rel="stylesheet" href="{{ 'theme.css' | asset_url }}"
      media="print" onload="this.media='all';this.onload=null">
<noscript><link rel="stylesheet" href="{{ 'theme.css' | asset_url }}"></noscript>

12. Is It Still Worth Doing?

Several things have changed since critical CSS became standard advice around 2015, and they all push in the same direction.

HTTP/2 and HTTP/3 removed the connection cost. A stylesheet on the same origin as the document reuses the existing connection. Under HTTP/1.1 with six connections per host and no multiplexing, a stylesheet was often queued behind other requests. That queueing is gone. The stylesheet still blocks paint, but it starts downloading immediately and shares bandwidth sensibly.

103 Early Hints. If your server can send a 103 with Link: </assets/main.css>; rel=preload; as=style before the main response, the browser starts fetching the stylesheet during your server's think time. On a site with a 400ms time to first byte, this can hide the entire stylesheet fetch behind work that was happening anyway. Where Early Hints is available and TTFB is nontrivial, it delivers a good share of critical CSS's benefit for a fraction of the maintenance. It is not a full substitute — the stylesheet still has to arrive and parse before paint — but it changes the arithmetic.

Stylesheets got smaller. Utility-first CSS with a purge step, or a component framework with proper tree-shaking, produces builds in the 10–25KB compressed range. Below about 20KB compressed, inlining the entire stylesheet is simpler than extracting part of it, has no state-matrix problem, and cannot go stale. I have done this on several projects and it is unambiguously the better answer when the size allows.

Compression improved. Brotli at level 11 on CSS regularly achieves 6:1 or better because CSS is enormously repetitive. A stylesheet that was 90KB gzipped might be 62KB Brotli. Free, one config line, no maintenance.

Where does that leave the technique? My position:

If your total CSS is under about 20KB compressed, inline all of it and stop thinking about this. No extraction, no state matrix, no staleness. This is the best outcome and it is reachable for more teams than think it is.

If your CSS is 20–50KB compressed, try the cheap wins first: Brotli, Early Hints, splitting the stylesheet by route so each page loads only what it needs, and deleting dead rules. A coverage audit on a Magento theme routinely finds 60–70% of the CSS unused on any given page. Route-splitting is less clever than critical CSS and it does not go stale.

If your CSS is over 50KB compressed, you cannot fix it quickly, your traffic is mobile-heavy, and your first-visit rate is high, then critical CSS earns its place — provided you also commit to the maintenance. And you should treat it as buying time to reduce the CSS, not as the permanent answer.

13. The Option Nobody Suggests: Ship Less CSS

Every hour spent on extraction pipelines is an hour not spent on the reason the stylesheet is 90KB.

What I find when I audit a large stylesheet, roughly in order of volume: multiple generations of design system living side by side because nothing was ever deleted; a full grid framework where four utility classes are used; vendor CSS for a slider that was replaced two years ago; a theme's default styles overridden rather than removed; and print styles, which should be in a separate stylesheet with media="print" and are frequently not.

The coverage audit is the tool.

// How much of the stylesheet does this page actually use? Run across your
// top ten templates and union the results; anything used by none is dead.
import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.coverage.startCSSCoverage();
await page.goto(url, { waitUntil: 'networkidle0' });
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await new Promise(r => setTimeout(r, 2000));   // let scroll-triggered styles apply
const coverage = await page.coverage.stopCSSCoverage();

for (const entry of coverage) {
  const used = entry.ranges.reduce((n, r) => n + r.end - r.start, 0);
  console.log(`${(used / entry.text.length * 100).toFixed(1)}% used  ${entry.url}`);
}
await browser.close();

Be careful with the conclusion. Coverage across ten templates is not coverage across your whole site, and deleting a rule because it was unused on the pages you sampled is how you break the returns form nobody visits. Union across a wide sample, exclude anything matched by a selector present in any template source, and delete conservatively. But delete. A 90KB stylesheet reduced to 35KB is a permanent, maintenance-free improvement that also makes every future critical CSS decision easier.

The related move is splitting by route rather than by criticality. One stylesheet for the shared shell, one per major template. Each page loads two files instead of one, both smaller, both cacheable, and neither derived from a headless crawl. Modern bundlers do this with almost no configuration, and it captures a large share of the benefit with none of the staleness.

14. A Worked Example

A specialist tools distributor, Magento 2.4, 3,000 SKUs, 71% mobile traffic, and a customer base that arrives largely from Google on a single-product search — so first visits with cold caches dominate.

Starting point. Theme CSS 412KB raw, 78KB gzipped, served from the same origin, gzip only. FCP on the throttled mobile profile was 3.1s; field p75 FCP 2.4s. LCP p75 3.9s.

First, the free things. Enabled Brotli at level 11 for static assets: 78KB gzip became 54KB Brotli. Moved print styles into their own media="print" stylesheet: another 6KB out of the blocking path. Total blocking CSS now 48KB compressed. FCP p75 moved from 2.4s to 2.15s for one afternoon of work and no ongoing maintenance.

Then the coverage audit. Across twelve templates, 63% of rules were unused everywhere. About half of that was safely deletable — an abandoned slider library, a second grid system, styles for a "compare products" feature switched off in 2022. Deleting it took a week including regression testing and brought the stylesheet to 29KB compressed.

At which point the plan changed. 29KB is small enough that route-splitting made more sense than extraction. Split into a 11KB shell plus per-template chunks of 4–9KB. Shell inlined entirely; template chunk loaded with the media="print" swap. No extraction pipeline, no state matrix, nothing to go stale.

Results. FCP p75 from 2.4s to 1.35s. LCP p75 from 3.9s to 2.6s. CLS unchanged at 0.04. The conversion rate on mobile moved about 4% over the following month, which I would not attribute confidently to this alone but is at least not contradictory.

What went wrong. The route-split chunks were keyed on Magento's full action name, and I missed that the layered navigation on category pages loads a partial via AJAX that pulls in styles belonging to the search results template. On a category page with filters applied, a chunk of the filter UI rendered unstyled for a moment. It took three weeks and a customer complaint to find, because it only happened on the second interaction and every synthetic test measured the first load.

What I would do differently. Start with the coverage audit rather than the delivery mechanism. I spent the first two days building an extraction pipeline that I then threw away, because once the stylesheet was 29KB the extraction was pointless. The deletion work was less interesting, harder to justify in a ticket, and produced the larger share of the improvement. That is usually how it goes and I still have to remind myself.

15. Measuring Whether It Worked

Three measurements, in order of how much I trust them.

Field FCP, split by cache state. Critical CSS helps cold-cache first visits and does approximately nothing for warm ones. Aggregate FCP will therefore understate the effect. If your RUM lets you segment on whether the stylesheet was served from cache, do that; if not, segment on new versus returning sessions as a proxy.

Field CLS, in the window before the deferred stylesheet applies. The observer shown above. This is the number that catches a bad extraction, and it is the one that goes wrong silently.

Lab FCP on a throttled profile. Useful for a before-and-after on a single change and useless as an absolute. Run it in CI on your top templates so a regression is caught at the PR rather than in a monthly report.

// CI gate: FCP on a throttled profile must not regress. Absolute thresholds
// are noisy; comparing against the baseline branch is what catches regressions.
import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';

const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless=new'] });
const result = await lighthouse(process.env.PREVIEW_URL, {
  port: chrome.port,
  onlyAudits: ['first-contentful-paint', 'cumulative-layout-shift', 'render-blocking-resources'],
  formFactor: 'mobile',
  throttlingMethod: 'simulate',
});

const fcp = result.lhr.audits['first-contentful-paint'].numericValue;
const baseline = Number(process.env.BASELINE_FCP_MS);
// 10% tolerance: Lighthouse's simulated throttling has real run-to-run variance
if (fcp > baseline * 1.1) {
  throw new Error(`FCP regressed: ${Math.round(fcp)}ms vs baseline ${baseline}ms`);
}
await chrome.kill();

16. Questions People Ask

"Can we just inline the whole stylesheet?" If it is under about 20KB compressed, yes, and you should. Above that you start paying for the redundancy on every page view and the initial HTML response grows past the point where it fits in the first round trip. Between 20 and 30KB it is a judgement call that depends on how many pages a typical session views.

"Does critical CSS help LCP or just FCP?" It reliably helps FCP. It helps LCP when the paint gate was the binding constraint on the LCP element — which is common when the LCP element is text and less common when it is an image whose download dominates. On the distributor above, FCP moved 1.05s and LCP moved 1.3s, but the LCP improvement included the effect of the image no longer competing with the stylesheet for bandwidth.

"What about CSS-in-JS?" If styles are generated at runtime by JavaScript, your critical CSS problem becomes a JavaScript execution problem, and the answer is server-side rendering with style extraction — which every serious CSS-in-JS library supports and which many teams have not enabled. Without it you have a page that cannot paint styled content until a JavaScript bundle has downloaded, parsed, and executed, which is considerably worse than a render-blocking stylesheet.

"Should I inline critical CSS on every page or only the entry pages?" Every page. You do not know which page a user enters on, and for a storefront the entry page is very often a product page found through search rather than the homepage.

"Our critical CSS is 40KB. Is that normal?" No, and it means one of three things: the extraction viewport is too tall, a below-the-fold component has a sliver in the viewport pulling in its whole rule set, or your CSS has such deep specificity chains that rendering one visible element drags in a large ancestor cascade. The last one is a stylesheet problem that critical CSS will not solve.

"Can we generate it at runtime instead of at build time?" Some CDNs and services offer this. It removes the build-time staleness problem and introduces a runtime dependency on a third party in your critical path, plus a cache that can serve one page's critical CSS to another. I have not seen it go well and would not choose it.

"Does inlining CSS break Content Security Policy?" A strict policy without 'unsafe-inline' for styles blocks inline <style> blocks. Use a nonce on the style element and include that nonce in the policy header — the same nonce mechanism you use for scripts. This is a common reason critical CSS silently stops working after a security review, so test it under the production CSP rather than a relaxed staging one.

"How do I handle dark mode?" If the theme is chosen by a prefers-color-scheme media query, include both branches in the critical CSS; they compress well because the declarations are near-identical. If the theme is stored in localStorage and applied by a script, that script must run before first paint and be inline, and your critical CSS must cover both attribute states. The alternative is a flash of the wrong theme, which users notice far more than a flash of unstyled content.

"We tried this and nothing improved." Check what your actual bottleneck is before assuming the technique failed. If your time to first byte is 900ms, the stylesheet was never the problem. If your LCP is an image discovered late by a script, the paint gate was not the constraint. Critical CSS moves one specific thing: the time between the HTML arriving and the first pixel appearing. If that interval was already short, there was nothing to win.

17. The Order I'd Work In

Check your compression first. If you are serving CSS with gzip rather than Brotli, or with no compression at all because a proxy is stripping the header, you can get 25–30% off the blocking payload this afternoon with no code change and no maintenance. I have found this on more sites than I expected to, including ones with dedicated performance vendors.

Then run the coverage audit across your top ten templates and look at the number. If it says 60% of your CSS is unused, the honest highest-value work is deleting it, and everything else in this article is a workaround for not having done that.

Move print styles out of the blocking path, and split by route if your bundler makes it cheap. Both are permanent and neither can go stale.

If your compressed blocking CSS is now under about 20KB, inline the whole thing and you are done. This is the outcome to aim for and it is where I have ended up on most recent projects.

Only if it is still large: build the extraction, write the state matrix as an explicit version-controlled list, budget the output at 14KB compressed, and put the CLS observer in production before you ship rather than after. Make the extraction step fail the build when it fails, and put a person's name on it.

And be honest with yourself about the last part. The technique is not hard; keeping it correct for three years across staff turnover, template changes, and a CSP rollout is hard. If the answer to "who owns this in a year" is a shrug, you are choosing between no critical CSS and eventually-stale critical CSS, and no critical CSS is the better of those two. The checkout that lost 18% had a working extraction pipeline. It was just extracting the wrong page.