Render-Blocking Resource Identification

When the HTML parser encounters a synchronous <script> or a non-deferred <link rel="stylesheet">, it suspends DOM tree construction until that asset is fetched, parsed, and evaluated. Every millisecond spent waiting directly extends First Contentful Paint (FCP) and Largest Contentful Paint (LCP) — the two Core Web Vitals most directly tied to initial rendering speed. This page shows you exactly how to locate those blocking assets, understand why each engine treats them differently, and eliminate the ones that are not genuinely critical.

Every number on this page comes from one worked example: the /pricing route of a mid-sized SaaS marketing site, measured cold-cache under Lighthouse’s mobile profile (1.6 Mbit/s down, a 150 ms round trip, 4× CPU slowdown). It ships a 68 kB app.css that pulls in two further sheets through @import, and a 42 kB consent script with no loading attribute. Baseline FCP is 1,120 ms; after the four fixes below it is 610 ms. Keeping one scenario throughout makes the difference between the measured cost of a blocker and the modelled cost Lighthouse reports much easier to see.


How Browsers Decide a Resource Blocks Rendering

The browser’s rendering pipeline requires two structures before it can paint a single pixel: the DOM (built by the HTML parser) and the CSSOM (built from all applied stylesheets). A resource is “render-blocking” if its absence prevents either structure from reaching a complete state.

The critical rendering path, visualised

The diagram below shows the four stages of the critical path with the example page’s real timings. A resource that stalls any stage delays everything to its right — and note that the two gates are independent: the DOM finishes 270 ms before the CSSOM does, and the earlier completion buys nothing.

The critical rendering path, and where each blocker lands Four boxes in a row — HTML parse, CSSOM build, layout and paint — joined by arrows. HTML parse completes at 780 ms after a synchronous script stalls it from 200 to 640 ms. CSSOM build completes at 1,050 ms because app.css chains two further sheets through @import. Layout adds 46 ms and paint fires First Contentful Paint at 1,120 ms. One page, two gates: /pricing on Slow 4G, cold cache HTML parse DOM construction complete 780 ms CSSOM build stylesheet eval complete 1,050 ms Layout style + geometry 1,050 → 1,096 ms Paint first pixel 1,096 → 1,120 ms stalled 200 → 640 ms consent.js, no attribute app.css → fonts.css → icons.css: 3 serial fetches waits for both +46 ms FCP 1,120 ms LCP 1,840 ms Two independent gates: the DOM is done at 780 ms, but no pixel can land until the CSSOM closes at 1,050 ms.

Spec-level definition

The HTML Living Standard does not model render-blocking as a property of a file. Every Document owns a render-blocking element set, and while that set is non-empty the document is render-blocked: the update-the-rendering steps run but produce no frame. Elements enter the set, do their work, and are removed; the first frame lands on the tick after the set empties.

Two conditions decide membership. An element is implicitly potentially render-blocking when it is a <link rel="stylesheet"> (or a <style> element, or a classic script) created by its own node document’s parser. And an element can only be added while the document “allows adding render-blocking elements” — which is true up until the body element exists. That is the mechanical reason behind the folk rule “put stylesheets in the head”: a <link> discovered after <body> opens is no longer eligible to join the set at all.

Parser-blocking is the separate, older mechanism. A <script> element without async or defer puts the parser into a blocked state: tokenisation stops, no new nodes are appended, and the speculative preload scanner is the only thing still making progress. A stylesheet does not do this — the parser keeps building DOM while app.css is in flight — but paint waits regardless. That asymmetry is why the DOM in the diagram above is complete at 780 ms and the first pixel still does not arrive until 1,120 ms.

The two mechanisms interact in one place that catches almost everyone. A classic script may call getComputedStyle(), so the standard requires a parser-blocking script to wait until every style sheet that precedes it in document order has loaded. The script’s bytes can be sitting in memory at 190 ms and still not execute until 1,050 ms, because a stylesheet above it in the <head> has not finished. In DevTools this shows up as a script with a long, unexplained gap between “Content Downloaded” and its execution block in the Performance panel’s main thread flame chart.

The browser’s preload scanner — a secondary, speculative tokeniser — can discover <link> and <script src> references in the raw HTML ahead of the main parser and start their fetches early, but it does not change when the main parser is allowed to resume. The fetch starts sooner; the block still applies. And the scanner only sees markup: anything the browser learns about from CSS text, from JavaScript, or from a server-side redirect is invisible to it.

From the last blocker to the first frame

Removing an element from the render-blocking set does not paint anything by itself. The frame lands on the next rendering opportunity, and that tick still has to do real work: recalculate style for every element against the newly complete CSSOM, run layout to produce geometry, paint display lists, and hand them to the compositor. On the example page that tail is 70 ms — 46 ms of style and layout plus 24 ms of paint and commit — which is why FCP is 1,120 ms and not 1,050 ms.

The size of that tail scales with DOM size and selector complexity, not with network conditions, so it is the part of FCP that throttling does not change and that a faster CDN cannot fix. It is also why two sites with identical waterfalls can post FCP values 200 ms apart: one has 800 elements and flat selectors, the other has 6,000 elements and a design system that leans on descendant combinators. When your blocking window is already under 200 ms, this tail is where the remaining time is, and the tool for it is the Performance panel’s main-thread flame chart rather than the Network panel.

The explicit opt-in and opt-out

The blocking attribute closes the loop in the other direction. blocking="render" on a <link>, <style> or <script> puts that element into the render-blocking set on purpose, even when it would otherwise be exempt — an async script, for instance. That is genuinely useful for the small class of code that must run before the first frame or cause a visible flip: an A/B experiment allocator, a dark-mode class toggle, a locale redirect. A deliberate 40 ms block on a 6 kB inline module beats an accidental 440 ms block on a synchronous bundle, and unlike a synchronous script it does not stop the parser.

The opposite side of the pair is media. Because the CSSOM specification defines an applicable style sheet as one that is not disabled and whose media list matches the current environment, a <link> whose media does not match never becomes applicable, never joins the render-blocking set, and never gates the first paint — the browser fetches it anyway, at Lowest, and applies it later if the environment changes. Splitting one bundle along those lines is the highest-leverage fix available on most sites and is worked through in full in eliminating render-blocking CSS with media queries.


Browser Engine Differences

Chromium, WebKit, and Gecko share the same HTML5 spec baseline but diverge in how aggressively they preload, when they consider a stylesheet to be “applying”, and how they handle edge cases around media attributes and <script type="module">. The differences that matter for identification work are summarised below; the wider question of how each engine ranks fetches is covered in Chrome vs Safari vs Firefox priority differences.

Behaviour Chromium (Blink) WebKit (Safari) Gecko (Firefox)
Preload scanner depth Deep: discovers <img srcset>, <picture>, CSS @import URLs Moderate: discovers <link>, <script>, <img> Deep: similar to Chromium
<script type="module"> blocking Deferred by default; fetches module graph in parallel Deferred; fetches module graph Deferred; fully parallel fetches
media="print" stylesheet trick Reliably non-blocking at parse time; swaps on media change Reliable Reliable
fetchpriority attribute Fully supported (M101+) Partial (Safari 17.2+) Supported (Firefox 132+)
<link rel="preload" as="style"> → render-block Does not make it render-blocking; still requires a separate <link rel="stylesheet"> Same Same
@import inside a stylesheet Blocks CSSOM; creates nested fetch chain Same Same; emits a console warning for deep chains
renderBlockingStatus in Resource Timing Supported (M107+) Not implemented Not implemented
Paint suppression while styles load Frame withheld until the render-blocking set empties Same, with an internal load timeout Same, plus the nglayout.initialpaint.delay timer before the first paint attempt

The most important cross-engine caveat: @import inside a stylesheet triggers additional blocking fetches that the preload scanner typically misses, because they are embedded in CSS text rather than HTML markup. Each @import adds at least one extra round-trip to the critical path.

The second is measurement asymmetry. PerformanceResourceTiming.renderBlockingStatus — the only first-party API that will tell you, in the field, whether the browser actually treated a given request as blocking — is Chromium-only. On Safari and Firefox you are inferring blocking status from timing correlation rather than reading it, so a fix that works in Chrome should still be validated against Safari’s FCP distribution separately rather than assumed. Safari’s preload scanner is also shallower: patterns that Blink rescues by speculating ahead (a stylesheet referenced far down a long <head>, an image inside a <picture> element) can cost Safari a full round trip that Chrome never pays, which shows up as a Safari-only FCP regression that no Chrome trace reproduces.


Spec/API Reference: Attributes and Directives That Affect Blocking

The table below covers every attribute and directive that changes whether or when a resource blocks rendering. Browser support follows the Baseline 2024 definitions.

Attribute / Directive Resource type Effect on blocking Browser support
(no attribute) <script src> Fully parser-blocking All
async <script> Non-blocking fetch; executes on arrival (may interrupt parse) All
defer <script> Non-blocking fetch; executes after DOMContentLoaded, in order All
type="module" <script> Implicitly deferred; module graph fetched in parallel All (Baseline 2018)
blocking="render" <link>, <style>, <script> Explicit opt-in: joins the render-blocking set even when exempt Baseline 2024
fetchpriority="high" <link>, <script>, <img> Elevates in the fetch priority queue; does not remove blocking status Baseline 2023
fetchpriority="low" Same Demotes in queue; non-critical assets cleared later Baseline 2023
media="print" <link rel="stylesheet"> Non-blocking initially; browser swaps when media matches All
disabled <link rel="stylesheet"> Sheet is not applicable, so never render-blocking; fetch is deferred until enabled All
rel="preload" as="style" <link> High-priority fetch; no blocking effect until <link rel="stylesheet"> references same URL All
rel="modulepreload" <link> Pre-declares a module graph edge; non-blocking, removes a serial hop Baseline 2023
@import CSS text Blocking; nested fetch chain; not discovered by preload scanner All
crossorigin="anonymous" on preload <link rel="preload"> Must match crossorigin on the consuming element or the preload is wasted All

One row deserves a warning label. disabled looks like a clean way to ship an inactive theme sheet, but Blink treats a disabled link as “do not fetch yet” rather than “fetch quietly” — flipping disabled = false at runtime starts the request at that moment, from a cold state, at a low priority. It is a good tool for a theme the user opts into and a bad one for a sheet you will need 50 ms later.


Where Blockers Actually Come From

Almost nobody writes a synchronous <script> in the <head> on purpose any more. Blockers arrive through five recurring channels, and knowing which one produced yours decides whether the fix is a one-line attribute change or a build-system conversation.

Source What it emits Why it blocks Usual fix
Tag manager / consent snippet A synchronous loader in <head> Vendor docs demand it “so no event is missed” defer, plus a stub queue so events buffer until it runs
Design-system bundle One large parser-discovered stylesheet Every route ships every component’s CSS Split by media, or extract a per-route critical subset
Font provider stylesheet A <link> that itself @imports or 302s Chained, scanner-invisible discovery Self-host the sheet; preload the font files directly
A/B testing snippet A blocking script that hides <body> until it runs Anti-flicker snippet is blocking by design Scope the hide to the tested container; add a hard timeout
Legacy polyfill bundle A synchronous script guarding old browsers Feature detection must precede app code type="module" / nomodule pair, or drop it entirely

Two of those deserve a note. The anti-flicker snippet used by most experimentation tools is not an accident — it deliberately blocks paint so visitors never see the control variant flash before the treatment applies. That is a legitimate use of blocking, but it is only defensible when the hide is scoped to the element being tested and carries a timeout that releases the page if the vendor script never arrives. A site-wide body { opacity: 0 } with a 4-second fallback is a self-inflicted 4-second FCP.

Tag managers are the other perennial. A single container script frequently accounts for more blocking time than the entire first-party bundle, and it is measured differently from first-party code because the injected children keep arriving after the container has “finished” — the technique for attributing that cost properly is covered in measuring tag manager blocking time, and the broader accounting in third-party resource impact mapping.

Font stylesheets round out the list. The sheet itself is render-blocking; the font files it references are not, because they are discovered during CSSOM construction and gate text rendering rather than the first frame. Treating them as the same problem leads teams to preload font binaries while leaving the blocking sheet untouched — the sequencing that font loading optimization and FOUT prevention untangles.


Step-by-Step Identification and Remediation

Step 1 — Establish a cold-cache baseline

Disable cache in Chrome DevTools (Network tab → Disable cache) and throttle to “Slow 4G”. Run five Lighthouse CLI iterations and average the results to reduce noise:

# Run five cold-cache Lighthouse audits and write JSON reports
# --throttling-method=devtools ensures CPU and network throttling apply together
for i in {1..5}; do
  lighthouse https://example.com \
    --output=json \
    --throttling-method=devtools \
    --preset=desktop \
    --output-path="run-$i.json"
done

Export the HAR from DevTools (Network → right-click → Save all as HAR with content). Filter entries where responseReceivedTime < domContentLoadedEventStart and priority is VeryHigh or High. These are your candidates.

# Triage a HAR without opening it: list every CSS/JS request that finished
# before DOMContentLoaded, newest-finishing first. The `_priority` field is a
# Chrome extension to the HAR format, so this only works on Chrome exports.
jq -r '
  (.log.pages[0].pageTimings.onContentLoad) as $dcl
  | .log.entries[]
  | select(.response.content.mimeType | test("css|javascript"))
  | select((.time + (.startedDateTime | fromdateiso8601 * 1000)) != null)
  | {url: .request.url, prio: ._priority, ms: (.time | round)}
  | select(.prio == "VeryHigh" or .prio == "High")
  | "\(.ms)ms  \(.prio)  \(.url)"
' pricing.har | sort -rn

Run this against two HARs — one before a change, one after — and diff the output. It is a cruder instrument than Lighthouse, but it reports what the browser measured rather than what a simulator modelled, which makes it the right tool when a modelled saving and a real one disagree.

Read the resulting panel in a fixed order, because the columns answer different questions. Priority tells you what the scheduler believed about the resource; start time tells you when it was discovered; duration tells you what the network cost; and anything still in flight at the FCP marker is, by definition, part of your blocking window. The panel below is the example page’s baseline.

Reading the Network panel: which of six requests gate the first paint A six-row table styled as the DevTools Network panel. The document, app.css, consent.js, fonts.css, icons.css and analytics.js are listed with priority, start time and duration. app.css, consent.js and the two @import sheets are marked as blocking; analytics.js at Lowest priority is not. A note explains that the two @import rows start only after the sheet importing them is parsed. DevTools Network panel, cold cache, Slow 4G — the rows that gate the first paint Request Priority Start Duration Blocks first paint? pricing (document) Highest 40 ms 320 ms the document app.css Highest 110 ms 360 ms yes — CSSOM consent.js — no attribute High 110 ms 410 ms yes — parser fonts.css — @import in app.css Highest 470 ms 310 ms yes — CSSOM icons.css — @import in fonts.css Highest 780 ms 270 ms yes — CSSOM analytics.js — async Lowest 820 ms 260 ms no Priority column first, then anything still in flight when the FCP marker lands. Both @import rows start only after the sheet importing them is parsed — the scanner never saw them. FCP fires at 1,120 ms; 940 ms of that window had no paintable frame.

Two rows in that panel are diagnostic on their own. consent.js sits at High rather than Highest — Blink ranks a parser-blocking script below a parser-discovered stylesheet — yet it is the row that stops DOM construction. And fonts.css starts at 470 ms, not 110 ms, despite being needed just as early: nothing in the HTML mentions it, so nothing could have fetched it sooner.

Step 2 — Run the Lighthouse render-blocking audit

# Extract just the render-blocking-resources audit from a Lighthouse JSON report
# jq selects the audit node and lists each blocking URL with its estimated savings
jq '.audits["render-blocking-resources"].details.items[] |
    {url: .url, wastedMs: .wastedMs}' run-1.json

The wastedMs field is Lighthouse’s estimate of how many milliseconds FCP would improve if that resource were eliminated or deferred. Sort by this value to prioritise remediation effort.

Treat it as a ranking, not an arithmetic total. Each wastedMs is produced by re-running Lantern — Lighthouse’s load simulator — with that one node marked non-blocking, so the values are independent counterfactuals against the same baseline and they overlap. On the example page they read 610 ms for app.css, 300 ms for consent.js and 270 ms for fonts.css; the actual blocking window is 940 ms, not 1,180 ms. Once you know which request to attack, the follow-on question is which bytes inside it are worth cutting, and that means joining the audit to the treemap — the technique in auditing render-blocking resources with the Lighthouse treemap.

Step 3 — Cross-reference with the Coverage tab

Open DevTools → More toolsCoverage, click the record button, reload the page, then stop. The Coverage tab shows every stylesheet and script with a breakdown of used versus unused bytes. A stylesheet flagged as render-blocking that is 80% unused is a strong candidate for splitting into inline critical CSS plus a deferred full stylesheet.

Coverage is run-scoped, which is the caveat that bites: nothing is clicked during the recording, so every modal, dropdown and validation path looks dead. Use it to rank candidates by how much of the sheet the initial view needs, not as proof that a rule is deletable. On the example page, app.css reports 71% unused at first paint — enough to justify extracting a critical subset, not enough to justify deleting three quarters of the file.

Step 4 — Inline critical CSS and defer the rest

Extract above-the-fold styles using a tool like Critters (Webpack/Vite plugin) or Penthouse (Node CLI), then apply the preload + media-swap pattern:

<!-- Step 4a: Inline only the styles required for above-the-fold content.
     This eliminates the CSSOM-blocking fetch for initial paint. -->
<style>
  /* Generated by Critters — do not edit manually */
  body { margin: 0; font-family: system-ui, sans-serif; }
  .hero { display: grid; min-height: 60vh; align-items: center; }
</style>

<!-- Step 4b: Preload the full stylesheet at high priority so it arrives
     quickly after FCP without holding up the initial render. -->
<link rel="preload" href="/styles.css" as="style" fetchpriority="high">

<!-- Step 4c: The media="print" trick makes this non-blocking at parse time.
     The onload handler swaps media to "all" once the stylesheet is parsed,
     applying full styles without a re-layout flash. -->
<link rel="stylesheet" href="/styles.css"
      media="print" onload="this.media='all'">

<!-- Step 4d: Fallback for users with JavaScript disabled. -->
<noscript><link rel="stylesheet" href="/styles.css"></noscript>

The onload attribute in step 4c is the part that breaks silently. A Content Security Policy without unsafe-hashes or an explicit hash for that handler blocks the inline attribute, the swap never runs, and the stylesheet stays at media="print" forever — a page that looks unstyled below the fold with no console error that names the cause. Where CSP is strict, bind the swap from an external script instead: select link[media="print"][data-swap] on DOMContentLoaded and set media = 'all', which is CSP-clean and behaves identically.

Size the inlined block deliberately. Critical CSS is paid for on every navigation because it lives in the HTML and cannot be cached separately, so a 6.4 kB inline block that saves a 360 ms blocking fetch is an excellent trade and a 40 kB one usually is not — past roughly 14 kB you are pushing the document past the first congestion window and adding a round trip to the resource that gates everything else.

Step 5 — Audit and fix script attributes

Build a dependency graph of your scripts. Scripts that do not read or write the DOM on load are safe for defer. Scripts that must execute before other scripts but can wait until after parse are also safe for defer (execution order is preserved). Scripts with no dependencies on other scripts and no DOM-write side effects on load can use async. The ordering guarantees each attribute does and does not give you are set out in script loading: async, defer and execution order.

<!-- Synchronous: blocks parser. Only acceptable for truly critical
     above-the-fold scripts with no async alternative. -->
<script src="/critical-polyfill.js"></script>

<!-- defer: fetched in parallel, executes after HTML parsing,
     in document order. Safe for most application scripts. -->
<script src="/app.js" defer></script>

<!-- async: fetched in parallel, executes immediately on arrival.
     Safe for completely independent scripts (analytics, chat widgets). -->
<script src="/analytics.js" async fetchpriority="low"></script>

<!-- type="module": implicitly deferred; module graph loaded in parallel.
     Use for modern bundled code with import/export statements. -->
<script type="module" src="/ui-components.js"></script>

<!-- blocking="render": the deliberate case. An async experiment allocator
     that must run before the first frame, without stopping the parser. -->
<script src="/experiments.js" async blocking="render"></script>

The consent script in the worked example is the common hard case: it genuinely must run before any third-party tag fires, but it does not need to run before the first paint. Moving it from no attribute to defer removed 440 ms from the parser stall and changed nothing about tag ordering, because defer preserves document order among deferred scripts.

Step 6 — Apply fetchpriority to reduce the blocking window

Even render-blocking resources benefit from fetch priority tuning. A critical stylesheet that arrives 200 ms earlier because it was correctly elevated to fetchpriority="high" reduces FCP by those 200 ms even though it still technically blocks. Conversely, demoting non-critical assets prevents them from contending with the critical stylesheet for the same HTTP/2 stream slots.

<!-- Elevate the critical stylesheet — arrives faster, shortens blocking window -->
<link rel="stylesheet" href="/critical.css" fetchpriority="high">

<!-- Demote non-critical stylesheet — yields bandwidth to critical resources -->
<link rel="stylesheet" href="/theme-extras.css" fetchpriority="low">

<!-- Demote third-party analytics script — fetched last in the priority queue -->
<script src="/vendor-analytics.js" async fetchpriority="low"></script>

On the worked example this step was worth 60 ms, not 600: app.css was already at Highest, and the only real inversion was a hero image competing with it for bandwidth. That is the usual shape of the result. fetchpriority is a corrective for a scheduler that has misjudged your page, not a lever you pull on a page the scheduler already reads correctly — which is why it belongs after the structural fixes rather than before them. Measure the priority column first; if nothing is ranked wrongly, skip this step and keep the attribute in reserve.

Step 7 — Close the discovery gap at the origin

Every fix so far shortens the blocking window from inside the document. The remaining slice is the gap before the document exists at all: on the example page the server spends 300 ms generating /pricing, and the browser cannot discover app.css until the first chunk of HTML arrives at 40 ms. A 103 Early Hints response lets the origin hand over the critical URLs during that dead time.

HTTP/1.1 103 Early Hints
Link: </css/app.css>; rel=preload; as=style
Link: </fonts/inter-var.woff2>; rel=preload; as=font; crossorigin

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8

The hint does not change blocking status — app.css is still render-blocking when the parser reaches it — but the fetch is already in flight, so the blocking window starts and ends earlier. Two rules keep this safe: only hint resources that are needed on every render of that route, since a wrong hint spends bandwidth the critical path wants, and never hint a URL whose hash changes per deploy unless the hint is generated from the same manifest the HTML is.

An Link: rel=preload header on the 200 response is the fallback where Early Hints is unavailable. It arrives with the document rather than before it, so it buys less, but it still beats a URL buried 40 lines into the <head>.


Verification Workflow

DevTools Network panel

  1. Open DevTools → Network tab. Enable the Priority column (right-click any column header).
  2. Reload the page with cache disabled and throttling set to “Slow 4G”.
  3. Look for stylesheets and scripts with priority VeryHigh that complete after the FCP marker line (visible in the Timing row at the bottom of the waterfall). Each one is extending your render-blocking window.
  4. Look for priority inversions: a font file at VeryHigh while your main stylesheet is at High indicates the scheduler has misjudged criticality — fix with fetchpriority.
  5. Check the Queued at and Stalled rows in each request’s Timing tab. A blocker that spent 300 ms stalled rather than downloading is a scheduling problem, not a payload problem — see diagnosing request queueing and stalled time for how to tell the two apart.

Confirm the blocking set from the page itself

Chromium exposes the browser’s own verdict through PerformanceResourceTiming.renderBlockingStatus. This is the one signal that does not require you to infer blocking from timing correlation — the value is "blocking" or "non-blocking", straight from the loader:

// Enumerate what the browser ACTUALLY treated as render-blocking.
// renderBlockingStatus is Chromium-only (M107+); the ?? guard keeps this
// harmless in Safari and Firefox, where the property is undefined.
const blockers = performance.getEntriesByType('resource')
  .filter((e) => e.renderBlockingStatus === 'blocking')
  .map((e) => ({
    url: new URL(e.name).pathname,
    // responseEnd is when the bytes landed; everything before FCP that is
    // still 'blocking' is, by definition, inside your blocking window.
    endedAt: Math.round(e.responseEnd),
    transferKB: Math.round(e.transferSize / 1024),
  }))
  .sort((a, b) => b.endedAt - a.endedAt);

console.table(blockers);
// /css/icons.css   1050  4 KB   ← last blocker to land = the real gate
// /css/fonts.css    780  6 KB
// /css/app.css      470 12 KB

The last row to land is the one that actually gates paint; everything above it in the list finished earlier and cost you nothing extra. That single ordering answers the question teams usually spend an afternoon on — which of four blockers to fix first.

PerformanceObserver snippet

Deploy this to production to measure render-blocking impact in real browsers:

// Observe FCP in the field. A value above 1800 ms indicates render-blocking
// resources are delaying initial paint for real users.
// The web-vitals library wraps PerformanceObserver with cross-browser safety checks.
import { onFCP, onLCP } from 'web-vitals';

onFCP(({ value, entries }) => {
  // Beacon to your RUM endpoint with the FCP value and connection type
  const connection = navigator.connection?.effectiveType ?? 'unknown';
  navigator.sendBeacon('/api/vitals', JSON.stringify({
    metric: 'FCP',
    value,           // milliseconds
    connection,      // '4g', '3g', '2g', or 'slow-2g'
    url: location.pathname,
  }));
});

onLCP(({ value }) => {
  if (value > 2500) {
    // LCP above 2.5 s — flag for investigation
    navigator.sendBeacon('/api/vitals', JSON.stringify({
      metric: 'LCP', value, url: location.pathname,
    }));
  }
});

Lighthouse CI in your pipeline

# Assert render-blocking budget in CI. Fail the build if any resource
# adds more than 200 ms of blocking time to FCP.
lighthouse-ci autorun \
  --assert.audits.render-blocking-resources.maxNumericValue=200 \
  --assert.audits.first-contentful-paint.maxNumericValue=1800

Keeping it fixed

Render-blocking regressions are re-introduced by people who never touched the loading code: a new marketing tag, a design-system upgrade that re-adds an @import, a framework release that changes how the <head> is emitted. A budget assertion on FCP catches the symptom late and vaguely. Asserting on the blocking set itself catches the cause, in the pull request that caused it:

// Playwright assertion: fail CI when a new render-blocking resource appears.
// The allowlist is the point — a blocker is fine if someone chose it on purpose,
// and the diff is what makes that choice visible in review.
const ALLOWED = new Set(['/css/app.css']);

test('no unapproved render-blocking resources', async ({ page }) => {
  await page.goto('https://staging.example.com/pricing', {
    waitUntil: 'networkidle',
  });
  const blocking = await page.evaluate(() =>
    performance.getEntriesByType('resource')
      .filter((e) => e.renderBlockingStatus === 'blocking')
      .map((e) => new URL(e.name).pathname)
  );
  const unexpected = blocking.filter((p) => !ALLOWED.has(p));
  expect(unexpected, `new blockers: ${unexpected.join(', ')}`).toEqual([]);
});

Run it against a Chromium project only, since renderBlockingStatus is undefined elsewhere and the filter would silently pass. Pair it with the Lighthouse CI assertion above: one guards the count of blockers, the other guards their cost, and a regression usually trips exactly one of them.

What the four fixes were worth

The diagram below is the same page before and after: critical CSS inlined, the @import chain flattened into the bundle, app.css moved to the preload plus media-swap pattern, and consent.js moved to defer. Nothing was deleted and total transfer changed by less than 2 kB — the entire gain comes from removing things from the render-blocking set.

Before and after: the same requests, 510 ms earlier first paint Two stacked timelines on a shared axis from 0 to 1,300 milliseconds. In the before panel the document, a chained CSS fetch lasting 940 ms and a synchronous consent script all precede a first contentful paint marker at 1,120 ms. In the after panel the same document and requests remain, but the CSS is non-blocking and the script is deferred, so the first contentful paint marker sits at 610 ms while both requests are still in flight. Same page, same bytes, same network: what leaving the render-blocking set is worth BEFORE — one bundle, two @import levels, one synchronous script document CSS script document 320 ms app.css → fonts.css → icons.css · 940 ms blocking consent.js (sync) 530 ms FCP 1,120 ms AFTER — 6.4 kB critical CSS inlined, imports flattened, script deferred document CSS script document 320 ms app.css — non-blocking consent.js (defer) FCP 610 ms ms 0 200 400 600 800 1,000 1,200

The after panel makes one point that the before/after headline numbers hide: app.css and consent.js are still on the wire when FCP fires. They did not get faster. They stopped being in the way — which is the only thing render-blocking work ever achieves, and the reason a “reduce your CSS” project so often moves the metric less than a media split that ships identical bytes.


Edge Cases and Gotchas

CORS and preload credential mismatch

A <link rel="preload" as="style"> without a matching crossorigin attribute will be fetched without credentials. If the actual <link rel="stylesheet"> element uses crossorigin="anonymous", the browser treats them as two different requests and the preload is wasted — you get a double fetch and the stylesheet still blocks. Always match the crossorigin attribute on the preload and the consuming element:

<!-- Correct: crossorigin attribute matches on both elements -->
<link rel="preload" href="/cdn-styles.css" as="style" crossorigin="anonymous">
<link rel="stylesheet" href="/cdn-styles.css" crossorigin="anonymous">

The mismatch is silent in the waterfall unless you look for it: two rows with the same URL, the first ending in (preload) and reporting a Provisional headers warning, the second doing the real work. Chrome’s console message — “The resource was preloaded using link preload but not used within a few seconds” — is the reliable tell, and it fires for the as attribute being wrong just as readily as for crossorigin.

The @import chain problem

CSS @import statements are not discoverable by the preload scanner because they appear inside CSS text, not HTML. Each @import creates a serialised, blocking fetch chain: the browser must download and parse the parent stylesheet before it discovers the @import URL, at which point it starts a new blocking fetch. On the example page that is exactly what the CSSOM gate is made of — three sheets, three serial discoveries, 940 ms of blocking for 22 kB of CSS.

Three chained @import discoveries versus one flattened bundle The upper row shows app.css, fonts.css and icons.css as three boxes joined by arrows: each is only discovered after the previous sheet is parsed, so the fetches run 110 to 470, 470 to 780 and 780 to 1,050 milliseconds. The lower row shows the same CSS bundled into one file that is discovered at 90 milliseconds and finishes at 520, making the CSSOM ready 530 milliseconds earlier for identical bytes. Each @import level is a serial discovery the preload scanner cannot make for you Chained: every sheet is found only after the previous one has been parsed app.css scanner finds it at 90 ms fetch 110 → 470 ms fonts.css @import, found at 470 ms fetch 470 → 780 ms icons.css @import, found at 780 ms fetch 780 → 1,050 ms +310 ms +270 ms Flattened at build time: one sheet, one discovery, one fetch app.css (imports inlined by the bundler) scanner finds it at 90 ms fetch 110 → 520 ms, CSSOM ready 520 ms 530 ms earlier CSSOM ready 520 ms, not 1,050 ms The bytes are identical. The only thing that changed is how many serial discoveries the browser has to make.

Eliminate @import in favour of bundling all stylesheets at build time, or use multiple <link rel="stylesheet"> elements, which the preload scanner can discover in parallel. Sass and Less @use/@import directives resolve at compile time and are not affected — the problem is only the CSS at-rule that survives into the shipped file. If a third-party sheet you do not control contains the @import, <link rel="preload"> for the imported URL restores parallelism, because the preload starts the fetch from markup the scanner can see.

media queries that match immediately

The media="print" deferral trick only works if the media query does not match the current viewport. If you use media="(max-width: 768px)" on a mobile device, the stylesheet is considered applicable, joins the render-blocking set, and becomes render-blocking. Use media="print" specifically, as it never matches a screen viewport during normal rendering.

That cuts both ways, and the cut is useful: a sheet with media="(min-width: 1024px)" is inapplicable on a phone and applicable on a desktop, so the same markup blocks paint for one visitor and not the other. That is the intended behaviour of a media split rather than a bug, but it means a Lighthouse mobile run and a Lighthouse desktop run legitimately disagree about which resources are render-blocking, and you need both before declaring a fix complete.

type="module" and the preload scanner

<script type="module"> is implicitly deferred, but the module graph — the chain of import statements — is not fully visible to the preload scanner. Modules that import other modules create additional network requests that may arrive later than expected, potentially stalling module execution even though the root script itself was fetched promptly. This is the @import problem in a different syntax, and it has the same shape of fix: declare the graph in markup with modulepreload so every hop starts at once.

<!-- Preload the root module and its primary dependency
     so all fetches happen in parallel rather than serially. -->
<link rel="modulepreload" href="/ui-components.js">
<link rel="modulepreload" href="/ui-components-utils.js">
<script type="module" src="/ui-components.js"></script>

Modules are not render-blocking by default, so a deep graph does not delay FCP directly. It delays interactivity and, when the module owns above-the-fold content, LCP — which is why a module waterfall often shows up as a Core Web Vitals problem with a clean render-blocking audit.

HTTP/2 multiplexing does not eliminate blocking

Under HTTP/2 multiplexing, multiple resources share a single connection — but the browser’s scheduler still applies priority weighting to determine which stream gets bandwidth first. If a low-priority resource has already claimed bandwidth before a critical stylesheet is discovered, the critical stylesheet can be delayed even on a fast connection. Correct fetchpriority attributes and proper resource ordering in the HTML head remain necessary even when HTTP/2 is in use.

Multiplexing also changes what a long bar in the waterfall means. Six parallel streams sharing one 1.6 Mbit/s pipe each take roughly six times as long as they would alone, so a blocking stylesheet can appear slow when the real problem is that eleven images were allowed to compete with it. Demoting the images with fetchpriority="low" shortens the stylesheet’s bar without changing a byte of CSS.

Cold-cache versus warm-cache profiling

Caching and stale-while-revalidate behaviour can mask render-blocking issues on repeat visits. A stylesheet served from disk cache adds negligible blocking time, making the problem invisible in warm-cache tests. Always measure with cache disabled to see the true first-visit cost, then measure with cache enabled to confirm your cache policy does not inadvertently cause blocking on stale revalidation.

The revalidation case is the one that gets missed. A stylesheet with must-revalidate, or one whose max-age has just expired, still issues a conditional request before it can be applied — and a 304 Not Modified costs a full round trip during which the document remains render-blocked. On a 150 ms connection that is 150 ms of blocking for zero bytes transferred, and it will never appear in a cold-cache Lighthouse run.

Inline <style> is not free

An inline <style> block never costs a network round trip, which makes it feel exempt from this whole discussion. It is not: a <style> element created by the parser is also implicitly render-blocking, and its cost is CPU rather than latency. The parser must tokenise the CSS, build rule sets, and index selectors before the CSSOM closes. On a mid-range phone with a 4× CPU multiplier, roughly 40 kB of inlined CSS costs about 30 ms of main-thread work that no amount of bandwidth removes.

Two consequences follow. First, “inline everything” is not a strategy — it converts a network problem into a CPU problem and moves the cost from first visits to every visit. Second, when a critical-CSS extraction tool is misconfigured and emits most of the sheet, the resulting page can measure worse than the version it replaced, on exactly the low-end devices the change was meant to help. Check the size of the generated block in CI, not just its presence.

The blocking window is not the sum of the durations

A row that took 360 ms did not necessarily add 360 ms to FCP. Three of the example page’s four blockers overlap in time, so the window — 940 ms — is shorter than the durations added together. What matters is the interval during which at least one element is in the render-blocking set, measured from the first blocker’s discovery to the last blocker’s responseEnd.

This is why per-resource savings estimates mislead when you read them as a list to be totalled, and why the honest way to report a fix is a before/after FCP pair from the same harness rather than a sum of wastedMs values. It also explains a frustrating class of result: removing the second-slowest blocker changes nothing at all, because the slowest one was covering it the whole time. Always attack the resource with the latest responseEnd, then re-measure before choosing the next.

Redirects on the critical stylesheet

A <link href="/css/app.css"> that 301s to /static/css/app.css doubles the blocking window’s round trips and defeats the preload scanner twice over: the scanner starts the first request early, learns nothing useful from the redirect response, and the real fetch begins after the round trip completes. On the example page’s connection profile that single redirect would add 150 ms to FCP. Fix the href, not the redirect.


FAQ

Do async scripts block rendering?

async scripts do not block HTML parsing while fetching, but they execute immediately upon download completion — which can interrupt an in-progress parse and briefly stall rendering. defer scripts are safer for most use cases: they fetch in parallel and execute only after the DOM is fully parsed, in document order.

Why does Lighthouse flag a stylesheet as render-blocking even when it’s preloaded?

A preloaded stylesheet still blocks rendering unless it is also deferred via the media="print" swap or an onload handler. rel="preload" only accelerates fetch priority; it does not change the spec requirement that the CSSOM must be complete before painting.

Does fetchpriority="high" on a stylesheet remove its render-blocking status?

No. fetchpriority controls queue position — a higher-priority stylesheet arrives sooner but it still blocks paint until CSSOM construction completes. Use fetchpriority="high" on your critical stylesheet to shorten the blocking window, not to eliminate it.

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

Parser-blocking halts HTML tokenisation so the DOM cannot grow. Render-blocking halts paint even after the DOM is complete — because CSSOM construction is still pending. A synchronous <script> is both parser-blocking and render-blocking. A non-deferred stylesheet is render-blocking but not necessarily parser-blocking (the parser can continue building DOM while the stylesheet fetches, but paint waits).

How do I measure render-blocking impact on real users?

Deploy the web-vitals library and beacon FCP and LCP from onFCP/onLCP callbacks, then read renderBlockingStatus from the Resource Timing entries in the same beacon so you know which resources the browser blocked on rather than guessing. Correlate spikes with your deployment log and filter by navigator.connection.effectiveType to distinguish blocking from network latency. For critical CSS violations specifically, measure fixing low-priority critical CSS requests to resolve priority inversion in the scheduler.

Can a stylesheet placed in the body block rendering?

It does not join the document’s render-blocking set, because implicit render-blocking only applies to elements the parser creates while the body element is still null. Blink nonetheless suppresses painting of the content that follows an unfinished in-body stylesheet, to avoid a flash of unstyled content — so a late <link> delays part of the page rather than all of it, which is worse to diagnose and no better to ship.

Why does a script that comes after a stylesheet wait for that stylesheet?

Because a classic script may call getComputedStyle(), the standard makes a parser-blocking script wait until every style sheet preceding it in the document has loaded. The script’s bytes may have arrived 800 ms earlier and it still cannot run. This is the mechanism behind the most confusing waterfall in front-end performance: a fast script sitting idle behind a slow stylesheet, stalling DOM construction that has nothing to do with CSS.

Lighthouse reports zero render-blocking resources but FCP is still slow. What now?

The audit only looks at requests the parser had to wait for, so it goes quiet on the three most common remaining causes: a slow document response (check TTFB — nothing can block before the HTML exists), a large inlined <style> block whose parse cost is CPU rather than network, and an oversized DOM whose style and layout tail dominates the frame. Open the Performance panel, find the FCP marker, and look left: if the main thread is busy the problem is work, and if it is idle the problem is waiting.

Does a service worker change render-blocking behaviour?

Not the rules, only the timings. A stylesheet served from Cache Storage is still render-blocking; it simply resolves in single-digit milliseconds instead of hundreds. The trap is the opposite case — a fetch handler that awaits something slow before responding puts that latency directly inside the blocking window, and a cold service worker start-up adds tens of milliseconds before the handler even runs. If a route regressed only for returning visitors, the worker’s response path is the first place to look.

Is there ever a reason to add blocking="render" deliberately?

Yes — for an async script that must run before the first frame, such as an experiment allocator or a theme switcher whose late execution would cause a visible flip. blocking="render" opts that element into the render-blocking set explicitly, giving you a bounded, intentional block that does not stop the parser, instead of the unbounded accidental one a synchronous <script> produces.