Skip to Content
Critical CSS Without Shipping Your Whole Stylesheet Twice
Advanced
0 likes
Tutorials

Critical CSS Without Shipping Your Whole Stylesheet Twice

Mark the first-screen rules once, split the stylesheet in the build, and confirm the rest loads without blocking.

The usual critical CSS setup puts the first-screen rules in the HTML and also leaves them inside the full stylesheet, so they ship twice. Split the file once, at build time, and each rule ships once.

Step 1: mark the critical block in your source

/* critical:start */
body { font-family: Arial, sans-serif; margin: 0; }
.hero { padding: 48px 24px; background: #034bff; color: #fff; }
.hero h1 { margin: 0 0 8px; font-size: 2rem; }
.btn { background: #fff; color: #034bff; padding: 12px 20px; border-radius: 6px; }
/* critical:end */
.grid { display: grid; gap: 16px; padding: 24px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.card { border: 1px solid #626976; border-radius: 8px; padding: 16px; }
.footer { padding: 24px; background: #080b0e; color: #f2f8ff; }
.tabs { display: flex; gap: 8px; }

The markers are comments, so the source file is still valid CSS. You decide what goes above the line by looking at the first screen at phone and desktop widths.

Step 2: split it in the build

import { readFileSync, writeFileSync, mkdirSync } from "node:fs";

const css = readFileSync("styles.css", "utf8");
const start = css.indexOf("/* critical:start */");
const end = css.indexOf("/* critical:end */");
if (start === -1 || end === -1) throw new Error("critical markers missing");

const critical = css.slice(start + "/* critical:start */".length, end).trim();
const rest = (css.slice(0, start) + css.slice(end + "/* critical:end */".length)).trim();

mkdirSync("dist", { recursive: true });
writeFileSync("dist/rest.css", rest + "\n");
const html = readFileSync("index.src.html", "utf8").replace("/*CRITICAL*/", "\n" + critical + "\n");
writeFileSync("dist/index.html", html);

const bytes = (s) => Buffer.byteLength(s);
console.log("styles.css  ", bytes(css), "bytes");
console.log("critical    ", bytes(critical), "bytes (inlined in index.html)");
console.log("rest.css    ", bytes(rest), "bytes");

Save it as build.mjs and run node build.mjs on Node 20. Output:

styles.css   569 bytes
critical     245 bytes (inlined in index.html)
rest.css     282 bytes

The two parts add up to 527 bytes. The 42 bytes that are missing are the two marker comments and the whitespace around the cut. No rule is in both files.

Step 3: the HTML template

<style>/*CRITICAL*/</style>
<link rel="stylesheet" href="rest.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="rest.css"></noscript>

Step 4: check it

I served dist/ and read the render-blocking status from Chrome 154:

rest.css status: non-blocking   media: all   hero background: rgb(3, 75, 255)   card border: 1px

The hero had its style from the inline block, and rest.css loaded without blocking and applied afterwards. The cards and footer are styled only when rest.css arrives.

⚠ Warning: Keep each component whole on one side of the cut. If half of a card is in the critical block and half is in rest.css, the first paint shows a half-styled card until the second file arrives.

Common mistakes

  • Marking half of a component as critical, which makes the first paint look broken.
  • Forgetting to rebuild when the first screen changes.
  • Dropping the noscript fallback.

Related course: HTML and CSS Advanced.

About

Mark the first-screen rules once, split the stylesheet in the build, and confirm the rest loads without blocking.

Responsible
Christopher R
Last Update
08/19/2026
Reading Time
3 min
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.