Speed Lab: inlining critical CSS

A Bootstrap landing page with its style sheet in the head, and with only the first screen's CSS inlined: first paint on two networks, layout shift and cost.

IntermediateHow-to7 min readUpdated October 30, 2026

In this guide
  1. The question
  2. The page
    1. The three variants
  3. Results
    1. When things happened
  4. What the speed-up costs
  5. The same test on a network with a 150 ms round trip
  6. What this test does not show
  7. What to do
  8. Run it yourself
  9. Mistakes to avoid
  10. Check your own page
  11. Sources

The question

The browser does not paint a page until it has received and processed the CSS in the head. MDN's guide to the critical rendering path says so: the rules in a late style sheet could overwrite earlier ones, so painting waits. A big style sheet therefore sits directly in front of the first paint. The usual cure is to put the few rules that the first screen needs inside the page, and to load the rest of the CSS without blocking. The critical CSS guide explains it. This test measures what it gains and what it costs.

The page

The page is a Bootstrap 5.3.3 landing page. The first screen holds a navigation bar and a hero: a heading, a lead paragraph and two buttons. Below it are six cards, an accordion of three questions, a table and a footer. The full style sheet, bootstrap.min.css, is 232,800 characters, and the server sends it with gzip as 31,028 bytes.

For the critical part I used PurgeCSS 6. I gave it only the markup of the first screen and the full style sheet, and it kept the rules whose selectors match those elements, plus the rules for html, body, :root and *. The result was 14,213 characters, 6% of the full sheet. Inside the page it adds 3.6 KiB after compression.

The method is the one in how the Speed Lab tests are run: Lighthouse 13.5.0 with its mobile preset and devtools throttling, and nine runs of each page, taken in turns.

The three variants

<!-- a. the style sheet in the head, blocking -->
<link rel="stylesheet" href="bootstrap.min.css">

<!-- b. the critical CSS inline; the full sheet is preloaded and switched on when it arrives -->
<style>/* 14,213 characters for the first screen */</style>
<link rel="preload" href="bootstrap.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="bootstrap.min.css"></noscript>

<!-- c. the critical CSS inline; the full sheet is loaded for print and switched to all screens when it arrives -->
<style>/* the same 14,213 characters */</style>
<link rel="stylesheet" href="bootstrap.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="bootstrap.min.css"></noscript>

Results

The first paint and the Largest Contentful Paint were the same moment in every run.

VariantFirst paintSpeed IndexTotal Blocking TimeLayout shiftSent
a. Blocking style sheet1,484 ms (1,456 to 1,512)1,493 ms0 ms031.6 KiB
b. Inline and preload698 ms (682 to 721)781 ms40 ms (21 to 101)0.01835.2 KiB
c. Inline and media="print"677 ms (659 to 718)763 ms38 ms (15 to 106)0.01835.2 KiB

Four things stand out.

  1. The first paint came about 800 ms sooner. That is 786 ms for variant b and 807 ms for variant c, a drop of more than half.
  2. Lighthouse had predicted it. For variant a, the "Render-blocking requests" insight listed the style sheet and estimated savings of 780 ms. The measured gain was within 30 ms of that.
  3. The two ways of loading the rest were equal. Their ranges overlap, so I treat b and c as the same.
  4. The gain was not free. Total Blocking Time went from 0 to about 40 ms, a layout shift of 0.018 appeared, and the page sent 3.6 KiB more.

When things happened

In all three variants the page arrived at about 580 to 600 ms and the full style sheet was requested at about 630 ms. In variant a it finished at 1,356 ms, and the page painted at 1,484 ms, 128 ms after. In variants b and c the style sheet finished at 1,369 and 1,355 ms, which is the same time, but the page had painted long before, at 698 and 677 ms, using the inline rules. The style sheet was no longer in front of the paint.

The two patterns gave the style sheet different priorities: Very High with the preload, Very Low with media="print". It made no difference here, because nothing else was competing for the connection. On a page with many files it could.

What the speed-up costs

The same test on a network with a 150 ms round trip

The 562.5 ms that applied throttling adds to every request is 3.75 times the round trip it stands for. A style sheet that blocks the paint is one request in a chain: the page, then the style sheet. Inlining the critical rules removes one round of that chain, so the lab can show a bigger gain than a visitor gets. I ran the pages again on a shaped network, with a round trip of 150 ms, a download link of 1,638.4 Kbps and slow start. The test of many small files has the details and the code. Five runs per variant.

VariantFirst paintSpeed IndexTotal Blocking TimeLayout shift
a. Blocking style sheet855 ms (849 to 891)863 ms0 ms0
b. Inline and preload437 ms (420 to 508)486 ms31 ms (10 to 76)0.018
c. Inline and media="print"482 ms (408 to 552)530 ms38 ms (12 to 64)0.018 (0 to 0.018)

The first paint came 418 ms sooner for variant b and 373 ms sooner for variant c, which is 49% and 44%. With applied throttling it was 786 and 807 ms, 53% and 54%. The direction and the costs are the same, and the saving in milliseconds is about half. The saving grows with the round trip, so it would be larger on a slower connection and smaller on a faster one.

What this test does not show

What to do

  1. Read the "Render-blocking requests" insight in your audit. It lists the style sheets and estimates the saving. If the figure is small, stop here.
  2. Make the sheet smaller first. Remove CSS the page does not use, and send it compressed. See CSS performance and the compression test.
  3. If the saving is large, inline the critical rules. Build them with a tool from the real first screen, at the widths your visitors use, and rebuild them when the design changes.
  4. Load the rest without blocking, with either pattern above, and keep a noscript copy.
  5. Keep the inline part small, because it travels with every page.
  6. Check three numbers afterwards: the first paint, the layout shift and the Total Blocking Time.

Run it yourself

Make a folder, run npm install bootstrap@5.3.3 purgecss@6 in it, and save this as make-critical.mjs. Run it with node make-critical.mjs. It writes the three pages, and the output is the same as the pages I tested. Then measure them with the runner on the method page: GZIP=1 node lab-runner.mjs critical-css 9 first-contentful-paint,total-blocking-time,cumulative-layout-shift.

// make-critical.mjs: node make-critical.mjs
// Needs: npm install bootstrap@5.3.3 purgecss@6
// Writes critical-css/blocking, critical-css/preload and critical-css/media: the same landing page, three ways of loading its style sheet.
import { readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs';
import { PurgeCSS } from 'purgecss';

const sheet = 'node_modules/bootstrap/dist/css/bootstrap.min.css';
const fullCss = readFileSync(sheet, 'utf8');
const nav = `<nav class="navbar navbar-expand bg-dark navbar-dark"><div class="container"><a class="navbar-brand" href="#">Example Shop</a><ul class="navbar-nav ms-auto"><li class="nav-item"><a class="nav-link active" href="#">Home</a></li><li class="nav-item"><a class="nav-link" href="#range">Range</a></li><li class="nav-item"><a class="nav-link" href="#help">Help</a></li></ul></div></nav>`;
const hero = `<header class="bg-light py-5"><div class="container"><h1 class="display-5 fw-bold">Furniture that lasts</h1><p class="lead col-lg-8">Solid wood tables, chairs and shelves, made to order and delivered to your door. Choose a design, a size and a finish.</p><a class="btn btn-primary btn-lg me-2" href="#range">See the range</a><a class="btn btn-outline-secondary btn-lg" href="#help">Ask a question</a></div></header>`;
const firstScreen = nav + hero;
const card = (i) => `<div class="col-md-4 mb-4"><div class="card h-100 shadow-sm"><div class="card-body"><h2 class="h5 card-title">Collection ${i}</h2><p class="card-text">A short description of the pieces in collection ${i}, with materials and care notes.</p><a class="btn btn-sm btn-outline-primary" href="#">Details</a></div></div></div>`;
const question = (i) => `<div class="accordion-item"><h3 class="accordion-header"><button class="accordion-button collapsed" type="button">Question ${i}</button></h3><div class="accordion-collapse collapse"><div class="accordion-body">Answer ${i}.</div></div></div>`;
const rest = `<main class="container py-5" id="range"><h2 class="mb-4">The range</h2><div class="row">${[1, 2, 3, 4, 5, 6].map(card).join('')}</div>
<h2 class="mt-5 mb-3" id="help">Questions</h2><div class="accordion" id="acc">${[1, 2, 3].map(question).join('')}</div>
<table class="table table-striped mt-5"><thead><tr><th>Size</th><th>Width</th><th>Depth</th></tr></thead><tbody><tr><td>Small</td><td>120 cm</td><td>60 cm</td></tr><tr><td>Medium</td><td>160 cm</td><td>80 cm</td></tr></tbody></table></main>
<footer class="bg-dark text-white-50 py-4"><div class="container">Example Shop</div></footer>`;

// The critical CSS: the rules whose selectors match the elements of the first screen, plus the page-wide ones.
const [{ css: critical }] = await new PurgeCSS().purge({ content: [{ raw: firstScreen, extension: 'html' }], css: [{ raw: fullCss }], safelist: { standard: ['html', 'body', ':root', '*'] } });
console.log('full', fullCss.length, 'critical', critical.length);

const page = (head) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Critical CSS test</title>${head}</head><body>${firstScreen}${rest}</body></html>`;
const variants = {
  blocking: page('<link rel="stylesheet" href="bootstrap.min.css">'),
  preload: page(`<style>${critical}</style><link rel="preload" href="bootstrap.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'"><noscript><link rel="stylesheet" href="bootstrap.min.css"></noscript>`),
  media: page(`<style>${critical}</style><link rel="stylesheet" href="bootstrap.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="bootstrap.min.css"></noscript>`),
};
for (const [name, html] of Object.entries(variants)) {
  mkdirSync(`critical-css/${name}`, { recursive: true });
  writeFileSync(`critical-css/${name}/index.html`, html);
  copyFileSync(sheet, `critical-css/${name}/bootstrap.min.css`);
}

Mistakes to avoid

Check your own page

Run the free audit and open the render-blocking insight. Write down the estimated saving and the first paint. After you inline the critical rules, run it again and compare the two, along with the layout shift.

Sources

Start here

Free website audit

Live Google data for any public page: mobile and desktop.

Mobile + desktop · Lighthouse lab test · Real-user Chrome data when Google has it

Rather have this done for you? Speed optimization · Book a call

Related guides

All guides →