My site scores 100 in PageSpeed Insights on mobile, and Lighthouse still lists three render-blocking requests on every page. On one run each row said 474 ms. The estimated saving for fixing all three was 50 ms. On the next run it was 0.
That gap is what this post is about. Most guides tell you to inline critical CSS and add
defer to your scripts. That advice is fine, but it skips the part that decides whether
the work is worth doing: what the insight actually measures. I read the insight's source in Chrome
DevTools, ran it six times against my own Next.js 16 site, and compared the fixes I have shipped
with the one I tried and rolled back.
The short version: the "Duration" column is how long each file took to download in a simulation, not what you would save. The number that matters is the Est savings line. In a Next.js App Router app the blocking requests are almost always your stylesheet and the small CSS files
next/fontcreates; the JavaScript is alreadyasync. Ship less CSS per page, usenext/fontinstead of a Google Fonts<link>, keep third-party scripts out of<head>, and measure before you reach forinlineCss.
What counts as a render-blocking request
The browser will not paint anything until it has the CSS that could change what it paints. So any of these, found before the first paint, holds the screen blank:
<link rel="stylesheet">in the<head>whosemediamatches the current screen (amedia="print"sheet does not block).- A stylesheet pulled in by
@importinside another stylesheet. It cannot even start until the first one has arrived, so it adds a whole round trip. - A classic
<script src>in the<head>withoutasync,deferortype="module".
A script in the <body> is parser-blocking: the HTML below it waits,
but whatever is above it can already be painted. That is the difference between render-blocking and
parser-blocking. DevTools only counts a body request as render-blocking when the browser fetched it at
the highest priority.
How Lighthouse decides, read from the source
The "Render blocking requests" insight in Lighthouse and PageSpeed Insights is the same code as the one in the DevTools Performance panel. It lives in RenderBlocking.ts. This is what it does:
| Rule | What it means for you |
|---|---|
| Only requests that finish before first paint are listed. | A stylesheet that arrives after the page has painted is not on the list, even if it is huge. |
Requests Chrome marks non_blocking, dynamically_injected_non_blocking or potentially_blocking are skipped. | async and defer scripts, and scripts added by JavaScript, never appear. |
| The "Duration" column is, in the source's words, "the download time of the network request, responseReceived - requestSent". | It is how long the file took, in a simulated slow 4G load. It is not a saving. |
| Rows under 50 ms get no duration. | The code says so plainly: stylesheets that loaded that fast "have minimal impact anyway". |
| Estimated savings: the simulator runs First Contentful Paint again with every listed request (and anything that depends on it) removed. | This is the real number. It is the gain if all of them stopped blocking at once. |
| LCP savings equal the FCP savings unless the LCP element is an image; then they are 0. | On an image-heavy page this insight cannot promise you anything for LCP. |
Two things follow from that. First, several stylesheets that download in parallel can each show the same duration, because the simulation gives them the same time window. The durations do not add up. Second, a long duration with near-zero savings means something else, usually the HTML itself, was on the critical path anyway.
What my own site showed
I ran Lighthouse 12.8.2 locally, mobile, simulated throttling, three times on the home page and three times on a blog post. My machine was busy during these runs (Total Blocking Time of 270 to 580 ms, which the live site does not have), so I am only quoting the insight table, not the scores.
| Page | Stylesheet (Brotli) | Duration per row | Est. savings |
|---|---|---|---|
| Home, run 1 | 24 KB + 1.8 KB + 1.4 KB | 150 ms, two rows blank | FCP 0 ms, LCP 0 ms |
| Home, run 2 | same | 512 ms, 300 ms, one blank | FCP 50 ms, LCP 50 ms |
| Home, run 3 | same | 471 ms on all three | FCP 0 ms, LCP 0 ms |
| Blog post, run 1 | 26 KB + 1.8 KB + 1.4 KB | 474 ms on all three | FCP 50 ms, LCP 0 ms |
| Blog post, run 2 | same | 492 ms on all three | FCP 0 ms, LCP 0 ms |
| Blog post, run 3 | same | 150 ms, two rows blank | FCP 0 ms, LCP 0 ms |
Same files, same build, and the durations jump between 150 and 512 ms while the savings never pass 50 ms. If I had chased the 474 ms row I would have spent a day to win a rounding error. When your savings line reads under about 100 ms, this insight is done; spend the time on whatever has bigger savings.
When it is not done, the numbers look different: a savings line in the hundreds of milliseconds, several stylesheets, often one on another domain. That is the case the rest of this post fixes.
What Next.js makes render-blocking
This is the <head> of one of my blog posts, built with Next.js 16 and the App
Router:
<link rel="stylesheet" href="/_next/static/chunks/1_j8iek1rqakd.css" data-precedence="next"/>
<link rel="stylesheet" href="/_next/static/chunks/236zu9p2zktpj.css" data-precedence="next"/>
<link rel="stylesheet" href="/_next/static/chunks/2_k33xfufgv2p.css" data-precedence="next"/>
- The first file is everything imported with
import "./x.css"in the layouts and pages for this route: Tailwind, global CSS, CSS Modules. Next merges them into one or a few chunks per route. - The other two come from
next/font. Each font you load adds its own small stylesheet with the@font-facerules (here 1.0 KB and 0.6 KB with Brotli). They block rendering like any other stylesheet. Settingpreload: falseon a font does not change that; it only stops the font file being preloaded. - The JavaScript does not appear. Of the 12 script tags on that page, 11 are
asyncand one is anoModulepolyfill that modern browsers skip. Next.js does this for you, so in an App Router app this insight is almost entirely about CSS.
You can list your own stylesheets and their real sizes in one go:
# every stylesheet the page links, with raw and Brotli size
URL=https://example.com/some-page
curl -s "$URL" | grep -oE 'href="/_next/static/[^"]+\.css"' | cut -d'"' -f2 | sort -u |
while read css; do
raw=$(curl -s "https://example.com$css" | wc -c)
br=$(curl -s -H 'Accept-Encoding: br' "https://example.com$css" | wc -c)
echo "$css raw=$raw br=$br"
done
And read the insight without opening the report:
npx lighthouse https://example.com/ --only-categories=performance \
--output=json --output-path=./lh.json --chrome-flags="--headless=new"
node -e '
const a = require("./lh.json").audits["render-blocking-insight"];
console.log("savings", a.metricSavings);
for (const i of a.details.items) console.log(i.wastedMs ?? "-", i.totalBytes, i.url);
'
Fix 1: ship less CSS on each page
This is the fix that held up for me. Tailwind generates one stylesheet from every file it scans, so on a site with a blog, tools and an admin area every page downloaded the classes of every other page: 270 KB raw, about 35 KB over the wire, all of it blocking.
In October I split the app into route groups, (home), (blog),
(tools), (pages) and admin, and made each one a root layout
that imports only its own stylesheet:
// src/app/(blog)/layout.tsx - a root layout, so it has its own <html> and <body>
import "@/styles/generated/blog.css";
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
A small prebuild script follows the imports from each group's files and writes a Tailwind entry that lists only those files as sources:
/* src/styles/generated/blog.css - written by a prebuild script */
@import "tailwindcss" source(none);
@import "../../app/globals.css";
@source "../../app/(blog)/blog/[slug]/page.tsx";
@source "../../components/site/article-chrome.tsx";
/* ...one line per file the (blog) layout can reach */
Pages now get 150 to 214 KB raw, 23.6 to 29.8 KB with Brotli, depending on the section. Two things to know before you copy this:
- Moving between two root layouts is a full page load, not a client-side navigation. That is fine between sections of a site, not inside one.
- Class names that never appear in your source files, for example in HTML stored in a CMS, are
not generated. I keep a short list of those and pass them to Tailwind with
@source inline(...).
If your site is one section, the same idea applies on a smaller scale: remove CSS libraries you only use on one page and import them in that page instead of the root layout. Next will then only link them on the routes that use them.
Fix 2: replace the Google Fonts link with next/font
A Google Fonts <link> is the worst kind of render-blocking request: a new
connection to fonts.googleapis.com, a stylesheet that blocks, and then another
connection to fonts.gstatic.com for the files.
<!-- app/layout.tsx: a render-blocking request to another origin, then the font files -->
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" />
next/font downloads the font at build time and serves both the CSS and the files from
your own domain:
// app/layout.tsx
import { Inter } from "next/font/google";
// Self-hosted at build time: the @font-face CSS ships from your own origin,
// and the latin woff2 file is preloaded.
const inter = Inter({ subsets: ["latin"], display: "swap" });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
The small @font-face stylesheet is still render-blocking, but it comes over the
connection the page is already using, so it no longer costs a DNS lookup and a TLS handshake. Load
fonts you only use for decoration with preload: false. I do that for a handwriting font,
because a high-priority preload made it compete with the main stylesheet on slow mobile.
Fix 3: inlineCss, but measure it
Next.js has an experimental option that replaces the stylesheet links with a
<style> tag in the HTML:
// next.config.ts - only worth it when the CSS is small
const nextConfig = {
experimental: {
inlineCss: true,
},
};
export default nextConfig;
On paper this removes the blocking request entirely. I tried it on 19 October with a 280 KB Tailwind
stylesheet and rolled it back the same day. The CSS ended up in the page twice, once in the
<style> tag and once more in the React Server Components payload, so the home page
HTML went from 520 KB to 1.38 MB. First Contentful Paint got worse, not better.
Inlining helps when the CSS is small, because it then fits in the first packets of the HTML. It
hurts when the CSS is large, because every page view downloads it again instead of reading it from
cache. So do Fix 1 first, then try inlineCss, compare the HTML size and FCP before and
after, and keep it only if both look right.
The other "critical CSS" tools you will read about are a different case. optimizeCss
(critters) works with the Pages Router. I have not tested it with the App Router, and I would not ship
the old rel="preload" onload trick for the main stylesheet in a Next app: the page paints
unstyled first and the layout jumps.
Fix 4: keep third-party code out of the head
Most third-party scripts in a Next app arrive one of two ways: a <script>
pasted into the root layout, or a stylesheet link from a widget vendor. The first is
render-blocking; the second usually is too. Use next/script, which never renders a
blocking tag:
import Script from "next/script";
// Never a plain <script src> in <head>: that one blocks rendering.
<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />
Use afterInteractive for things that must run early, like consent, and
lazyOnload for chat, embeds and analytics. On this site Google Analytics and Clarity
wait until the visitor interacts, which I described in
how I got PageSpeed 100 on mobile with
Next.js. For widget CSS you only need on some pages, import it in those pages, not the root
layout.
Fix 5: make the HTML arrive sooner
A stylesheet cannot start downloading until the browser has the HTML that links it. If your server takes 600 ms to answer, every render-blocking request starts 600 ms late, and inlining CSS changes nothing about that. On my site, caching the HTML at Cloudflare cut time to first byte to about 40 ms, which did more for First Contentful Paint than anything I did to the CSS. If your HTML is not being cached, cf-cache-status: DYNAMIC explains why and how to fix it.
A checklist
- Read the Est savings line, not the Duration column. Under ~100 ms: move on.
- List every stylesheet the page links and its Brotli size (the curl loop above).
- Look for any blocking request to another domain: Google Fonts, icon fonts, widget CSS.
- Replace Google Fonts links with
next/font; setpreload: falseon decorative fonts. - Move page-specific CSS imports out of the root layout.
- On a big Tailwind site, give each section its own stylesheet.
- Move every third-party script to
next/scriptwithafterInteractiveorlazyOnload. - Only then test
inlineCss, and compare HTML size and FCP before keeping it. - Check your time to first byte. Blocking CSS cannot start before the HTML arrives.
The JavaScript side has its own insight. If "Reduce unused JavaScript" is what is left on your report, I wrote up what fixed it on a Next.js site.
If you would rather someone else did this for your site, I take on Next.js performance work.





Join the conversation
No comments yet — be the first to share what you think.