DEV Community

Cover image for Render-Blocking Requests in Next.js: What the Insight Measures
Kausar Raza
Kausar Raza

Posted on Originally published at knowledgemarkg.com

Render-Blocking Requests in Next.js: What the Insight Measures

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/font creates; the JavaScript is already async. Ship less CSS per page, use next/font instead of a Google Fonts <link>, keep third-party scripts out of <head>, and measure before you reach for inlineCss.

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> whose media matches the current screen (a media="print" sheet does not block).
  • A stylesheet pulled in by @import inside 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> without async, defer or type="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"/>
Enter fullscreen mode Exit fullscreen mode
  • 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-face rules (here 1.0 KB and 0.6 KB with Brotli). They block rendering like any other stylesheet. Setting preload: false on 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 async and one is a noModule polyfill 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
Enter fullscreen mode Exit fullscreen mode

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);
'
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 */
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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

  1. Read the Est savings line, not the Duration column. Under ~100 ms: move on.
  2. List every stylesheet the page links and its Brotli size (the curl loop above).
  3. Look for any blocking request to another domain: Google Fonts, icon fonts, widget CSS.
  4. Replace Google Fonts links with next/font; set preload: false on decorative fonts.
  5. Move page-specific CSS imports out of the root layout.
  6. On a big Tailwind site, give each section its own stylesheet.
  7. Move every third-party script to next/script with afterInteractive or lazyOnload.
  8. Only then test inlineCss, and compare HTML size and FCP before keeping it.
  9. 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.

FAQ

What are render-blocking requests?

Files the browser has to download before it can paint anything: stylesheets in the head, stylesheets loaded with @import, and scripts in the head without async, defer or type="module". Until they arrive the screen stays blank, which delays First Contentful Paint and often Largest Contentful Paint.

What does the Duration column in the render-blocking insight mean?

It is how long each request took to download (from request sent to response received) in Lighthouse's simulated slow-4G load. It is not the time you would save. Rows under 50 ms are shown without a duration. The estimated savings line, which re-simulates the page without those requests, is the number to act on.

Why does Lighthouse show render-blocking requests but 0 ms savings?

Because removing them would not make the page paint sooner. Usually the HTML download or the server response is the slow part, and the stylesheets finish within that time anyway. LCP savings are also always 0 when the LCP element is an image.

How do I fix render-blocking CSS in Next.js?

Ship less CSS per page (import page-specific CSS in that page, not the root layout; on a big Tailwind site give each section its own stylesheet), use next/font instead of a Google Fonts link, and only then test experimental.inlineCss. Measure HTML size and First Contentful Paint before and after each change.

Should I use inlineCss in Next.js?

Only if your CSS is small, and only after measuring. On my site, with a 280 KB Tailwind stylesheet, inlineCss put the CSS into the HTML twice (a style tag and the React Server Components payload), the home page went from 520 KB to 1.38 MB, and First Contentful Paint got worse.

Is Tailwind CSS render-blocking?

Yes, like any stylesheet linked in the head. Tailwind only generates the classes it finds in your source files, so the size depends on how much of the site one stylesheet covers. Splitting a large site into sections that each get their own generated stylesheet cuts what every page has to download.

What is the difference between render-blocking and parser-blocking?

A render-blocking request stops the browser from painting anything until it arrives, like a stylesheet in the head. A parser-blocking request, like a classic script in the body, stops the browser from reading the HTML below it, but content above it can already be painted.


Originally published on knowledgemarkg.com.

Top comments (0)