Splitting Layered CSS Into Critical and Deferred Bundles
Inlining above-the-fold CSS is standard performance work, and it is the single most reliable way to silently destroy a layer architecture. Extraction tools flatten what they extract, so rules that lived in reset come back as unlayered CSS — which outranks every layer in the deferred bundle that arrives moments later. The fix is small but non-obvious. This extends build-pipeline layer automation in the architecture patterns reference.
Prerequisites
A layered stylesheet with a manifest, and a critical-CSS step in the build. Knowing that first declaration fixes a layer’s position is the key fact the whole technique rests on.
/* The manifest, as it exists today in one bundle. */
@layer reset, base, theme, components, utilities;Step 1: Emit the manifest into both bundles
/* critical.css (inlined) — WHY the statement is repeated here: whichever
bundle the browser parses first registers the order, and the inline block
always parses first. Without this line the critical rules would define the
order by their own appearance sequence. */
@layer reset, base, theme, components, utilities;
@layer reset { *, *::before, *::after { box-sizing: border-box; } }
@layer components { .site-header { … } }/* deferred.css — WHY the SAME statement: a layer statement naming already
registered layers is a no-op, so this costs nothing when the critical
block ran, and saves the page when it did not (a cached HTML shell, a
bot, an error page that skipped inlining). */
@layer reset, base, theme, components, utilities;
@layer components { /* …the remaining 200 rules… */ }What this does: makes both bundles independently sufficient to establish the order. This is the whole technique; everything else is plumbing.
Step 2: Make the extraction layer-aware
// scripts/critical.mjs
// WHY not the default extractor output: most tools return matched rules as a
// flat string, which loses the @layer wrapper. Re-wrapping by walking the
// source AST keeps each rule in the layer it came from.
import postcss from 'postcss';
import { readFile } from 'node:fs/promises';
export async function extractCritical(cssPath, usedSelectors) {
const root = postcss.parse(await readFile(cssPath, 'utf8'), { from: cssPath });
const out = postcss.root();
root.walkAtRules('layer', (layerRule) => {
if (!layerRule.nodes) return; // a statement, not a block
const kept = postcss.atRule({ name: 'layer', params: layerRule.params, nodes: [] });
layerRule.walkRules((rule) => {
// WHY match against the rendered selectors: extracting by heuristic
// ("everything above 900px") keeps rules that never match and misses
// ones that do.
if (rule.selectors.some((s) => usedSelectors.has(s.trim()))) {
kept.append(rule.clone());
}
});
if (kept.nodes.length) out.append(kept);
});
// WHY prepend the manifest: the inline block parses first, so it must
// register the full order, not just the layers it happens to contain.
return `@layer reset, base, theme, components, utilities;\n${out.toString()}`;
}What this does: produces a critical block whose rules are still inside their original layers, so the deferred bundle can override them exactly as the source intended.
Step 3: Inline the critical block before the deferred link
<head>
<!-- WHY inline and first: it registers the manifest and paints the fold
without a network round trip. -->
<style>%CRITICAL_CSS%</style>
<!-- WHY this pattern: `media="print"` makes the fetch non-blocking, and
the onload swap applies it as a normal stylesheet once it arrives.
The noscript copy covers scripting-disabled clients. -->
<link rel="stylesheet" href="/assets/deferred.css" media="print"
onload="this.media='all'">
<noscript><link rel="stylesheet" href="/assets/deferred.css"></noscript>
</head>What this does: gets the fold painted immediately while the remainder loads without blocking. Note that the deferred stylesheet arrives after the inline block in document order, which is what makes later layers able to override earlier ones as usual.
Step 4: Verify the merged order
// WHY verify the MERGED result rather than each bundle: the failure this
// technique guards against only exists once both are present.
const order = [...document.styleSheets].flatMap((s) => {
try { return [...s.cssRules]; } catch { return []; }
}).filter((r) => r.constructor.name === 'CSSLayerStatementRule')
.flatMap((r) => r.nameList);
console.assert(
JSON.stringify([...new Set(order)]) ===
JSON.stringify(['reset', 'base', 'theme', 'components', 'utilities']),
'merged layer order does not match the manifest'
);What this does: catches the two realistic failures — a critical block that flattened, and a build where the manifest reached only one bundle.
Verification
- Load the page with the network throttled and confirm no visual shift when the deferred bundle applies — a shift means the critical block is winning conflicts it should lose.
- Confirm the critical block contains
@layerwrappers:
grep -c "@layer" dist/critical.css # must be > 1 (manifest + at least one block)- Disable JavaScript and confirm the
noscriptfallback still loads the deferred bundle. - Test the cached-shell case: serve the HTML without the inline block and confirm the page still renders correctly from the deferred bundle alone.
Troubleshooting
The page flashes and then corrects itself when the deferred CSS loads. : The critical block flattened its rules to unlayered CSS, so it wins until the deferred bundle… does not override it. What you are seeing is a genuine conflict, not a timing artefact. Re-check the extraction.
The deferred bundle never applies.
: The onload swap did not run — often because a Content Security Policy blocks inline event handlers. Attach the listener from an external script instead.
Layer order differs between the first visit and a reload. : One bundle is being served from cache without the other. The duplicated manifest is exactly the guard for this; confirm it is present in both files after minification.
Minification removed the duplicate layer statement. : Some minifiers treat a repeated statement as dead code. Verify the emitted files rather than the source, and disable that optimisation if needed.
Critical CSS is larger than the whole stylesheet used to be.
: Layer-aware extraction repeats the @layer wrapper per band, which adds bytes. It is still a fraction of the full sheet — but if the critical block exceeds about 14KB compressed, extract fewer selectors rather than abandoning the wrappers.
Complete working example
// scripts/build-critical.mjs — complete, layer-preserving critical extraction.
import postcss from 'postcss';
import { readFile, writeFile } from 'node:fs/promises';
import { chromium } from 'playwright';
const MANIFEST = '@layer reset, base, theme, components, utilities;';
/** WHY collect selectors from a real render: heuristics based on viewport
* position keep rules that never match and miss ones that do, and both
* errors are invisible until a user hits the page. */
async function usedSelectors(url, viewport = { width: 1280, height: 720 }) {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport });
await page.goto(url, { waitUntil: 'networkidle' });
const used = await page.evaluate(() => {
const found = new Set();
const walk = (rules) => {
for (const r of rules) {
if (r.cssRules) { walk(r.cssRules); continue; }
if (!r.selectorText) continue;
for (const sel of r.selectorText.split(',')) {
const clean = sel.trim();
try {
// WHY check for an element ABOVE THE FOLD specifically: a rule
// matching only below it does not need to be inline.
for (const el of document.querySelectorAll(clean)) {
if (el.getBoundingClientRect().top < window.innerHeight) {
found.add(clean);
break;
}
}
} catch { /* :hover, ::backdrop and friends — not queryable */ }
}
}
};
for (const s of document.styleSheets) {
try { walk(s.cssRules); } catch { /* cross-origin */ }
}
return [...found];
});
await browser.close();
return new Set(used);
}
const used = await usedSelectors('http://localhost:4173/');
const root = postcss.parse(await readFile('dist/assets/index.css', 'utf8'));
const critical = postcss.root();
// WHY walk layer blocks rather than rules: this is the step that preserves
// the enclosing @layer, which is the entire point of the exercise.
root.walkAtRules('layer', (layerRule) => {
if (!layerRule.nodes) return; // statement form
const kept = postcss.atRule({
name: 'layer', params: layerRule.params, nodes: [],
});
layerRule.walkRules((rule) => {
if (rule.selectors.some((s) => used.has(s.trim()))) kept.append(rule.clone());
});
if (kept.nodes.length) critical.append(kept);
});
// WHY the manifest in BOTH outputs: whichever parses first registers the
// order, and either may be first depending on caching and error paths.
await writeFile('dist/critical.css', `${MANIFEST}\n${critical.toString()}\n`);
const full = await readFile('dist/assets/index.css', 'utf8');
if (!full.trimStart().startsWith('@layer')) {
await writeFile('dist/assets/index.css', `${MANIFEST}\n${full}`);
}
console.log(`critical: ${critical.nodes.length} layer block(s), ` +
`${(await readFile('dist/critical.css')).length} bytes`);Frequently Asked Questions
Does duplicating the layer statement in both bundles cause a problem?
It does not. A layer statement that names layers already registered is a no-op — position is fixed by first declaration and no later statement can move it. That property is precisely what makes duplication the right tool here: whichever bundle the browser happens to parse first establishes the correct order, and the other one costs a few bytes and changes nothing.
Why does critical CSS extraction break cascade layers?
Because most extractors collect the matching rules and re-emit them as a flat list, discarding the at-rules that contained them. A rule that lived in reset comes out unlayered, and unlayered author styles outrank every layer — so the small critical block starts winning conflicts against the full stylesheet that arrives a moment later. The symptom looks like a loading race, but it is a permanent cascade inversion.
Should the critical block contain a partial layer or a whole one?
Partial, and that is entirely normal. A layer can be opened and populated any number of times from any number of files, so a critical block contributing three rules to components and a deferred bundle contributing two hundred more merge into one layer with the source order preserved. Trying to keep whole layers together would put most of the stylesheet inline and defeat the split.
Related
- Build-Pipeline Layer Automation — the wider build integration
- Automating Layer Order With PostCSS and Vite — emitting the manifest reliably in the first place
- Generating Layer Imports With a Sass Build Step — the same ordering guarantee under a preprocessor
- Testing Cascade Layers Across Browsers — asserting the merged order in CI
Up: Build-Pipeline Layer Automation → Architecture Patterns & Design System Scaling