Auditing Render-Blocking Resources with the Lighthouse Treemap

Lighthouse tells you /assets/app.a91f.js costs 412 ms of First Contentful Paint and then stops talking — this guide joins the render-blocking-resources audit to script-treemap-data, the audit that actually powers the Treemap view, so every blocking millisecond is attributed to the module that shipped it.

Root cause: two audits, two units, one shared field

The render-blocking resource identification workflow gets you a list of URLs that gate first paint. That list is where most teams stall, because a URL is not an actionable unit: app.a91f.js is one row in the audit and forty modules in your repository, and the row cannot tell you which of the forty is worth cutting.

The two numbers you need come from audits built on completely different inputs. render-blocking-resources is an opportunity audit driven by Lantern, Lighthouse’s load simulator. Lantern builds a dependency graph from the trace and the network log, then re-simulates that graph with the candidate node marked non-blocking; wastedMs is the difference between the two simulated First Contentful Paint values. It is a modelled millisecond count, keyed by request URL, and it knows nothing about what is inside the request.

script-treemap-data is assembled from three gatherer artifacts: Scripts (the script bodies as the browser received them), SourceMaps (each map Lighthouse could fetch from a sourceMappingURL comment), and JsUsage (block-level coverage collected over the Chrome DevTools Protocol Profiler domain). Lighthouse walks each script’s mappings, assigns every generated byte back to an original source file, and emits a tree of { name, resourceBytes, unusedBytes, children }. Those are uncompressed source bytes, keyed by module path, and they know nothing about timing.

The join key is the script URL, and it is the only field the two audits have in common. Top-level nodes in script-treemap-data are named after the script URL exactly as render-blocking-resources reports it, which is what makes a single jq pass enough to turn “412 ms” into “92 KB of locale data that never executed”.

Two Lighthouse audits, two units, one join key Three artifacts on the left feed two audits in the middle. The trace and Lantern graph produce render-blocking-resources with wastedMs values of 412, 268 and 180 milliseconds per URL. The Scripts, SourceMaps and JsUsage artifacts produce script-treemap-data with 214 kilobytes of resource bytes and 168.4 kilobytes unused. Joining the two on the script URL yields the blocking bytes that never run. Two audits, two units, one join key Artifacts from one run Audits in the JSON report What you can compute Trace + Lantern simulated load graph Scripts + SourceMaps bytes to module paths JsUsage block-level coverage render-blocking-resources wastedMs, keyed by URL 412 | 268 | 180 ms script-treemap-data resourceBytes per module unusedBytes per module 214 KB, 168.4 KB dead Blocking bytes that never run 168.4 KB of 214 KB The two audits share exactly one field: the script URL. One side is milliseconds of simulated paint; the other is uncompressed source bytes.

What the treemap is not measuring

Three properties of unusedBytes decide how much weight the number deserves. It is uncompressed: resourceBytes is the decoded length of the script, so a 92 KB dead node behind Brotli may only be 24 KB of transfer and roughly a quarter of the millisecond cost you were hoping for. It is run-scoped: coverage is stopped when Lighthouse finishes its load, so a module that executes on a timer 3 s after paint is recorded as fully used even though it had no business blocking the parser. And it is interaction-free: the run never clicks anything, so every modal, every validation path and every route behind a button looks dead.

None of that makes the number useless. It makes it a ranking signal rather than a verdict, which is exactly what you want when a 214 KB bundle has forty candidates and you have an afternoon.

Minimal reproduction

The shape that produces this symptom is a single synchronous bundle in the head, built without route splitting, with source maps emitted but not deployed. Three files reproduce it.

<!doctype html>
<meta charset="utf-8">
<title>Pricing</title>
<!-- Classic script with no defer: the parser suspends here until the bytes arrive AND
     the whole bundle has been compiled and evaluated. Every module inside it — including
     ones this route never calls — is on the critical path purely by being in the file. -->
<script src="/assets/app.a91f.js"></script>
<link rel="stylesheet" href="/assets/main.4b2c.css">
<h1>Pricing</h1>
// vite.config.js — maps must be BUILT and SERVED, not just built. Lighthouse fetches the
// sourceMappingURL target at audit time; if that fetch fails the treemap degrades to one
// opaque node per script and module-level attribution becomes impossible.
export default {
  build: {
    sourcemap: true,        // emits app.a91f.js.map next to the bundle
    rollupOptions: {
      output: {
        // A single entry chunk on purpose: this is the anti-pattern being reproduced.
        manualChunks: undefined
      }
    }
  }
};
# --throttling-method=simulate is the default and is what makes wastedMs a Lantern
# counterfactual rather than an observed delta. Keep it here: the treemap join below
# compares modelled milliseconds against static bytes, and mixing in devtools throttling
# would make the two columns describe different load conditions.
lighthouse https://staging.example.com/pricing \
  --preset=desktop \
  --output=json --output=html \
  --output-path=./pricing.report.json

Open pricing.report.html, press View Treemap, and you get the picture. Keep the JSON: it is the only form you can join, diff between runs, and assert on in CI.

Reading the treemap for one blocking script

The audit’s nodes array holds one entry per script, each with a children tree mirroring the source-map paths. Join it to the blocking list first, then flatten the winner.

# Join on the only shared field — the script URL — so wastedMs and unusedBytes land on the
# same row. Ranking by dead bytes rather than by wastedMs is the point: wastedMs tells you
# which request to attack, dead bytes tell you what inside it is actually removable.
jq -r '
  [ .audits["render-blocking-resources"].details.items[] | {url, wastedMs} ] as $blocking
  | [ $blocking[].url ] as $urls
  | .audits["script-treemap-data"].details.nodes[]
  | . as $n
  | select($urls | index($n.name))
  | ($blocking[] | select(.url == $n.name) | .wastedMs) as $ms
  | [ $n.name, $ms, $n.resourceBytes, ($n.unusedBytes // 0) ] | @tsv
' pricing.report.json
/assets/app.a91f.js       412   214016   172441
/assets/vendor.7c30.js    268   138240    41302

Then flatten the worst offender to leaves. Sorting by dead bytes descending puts the cut list in order.

# `leaves` is hand-rolled recursion: jq's builtin `walk` rebuilds a tree rather than
# emitting a flat list, and only leaves carry a meaningful unusedBytes — parent nodes
# aggregate their children and would double-count in any ranking.
jq -r '
  def leaves($path):
    ($path + "/" + .name) as $p
    | if (.children // []) == []
      then [ { path: $p, bytes: .resourceBytes, dead: (.unusedBytes // 0) } ]
      else [ .children[] | leaves($p) ] | add
      end;
  .audits["script-treemap-data"].details.nodes[]
  | select(.name | endswith("app.a91f.js"))
  | leaves("") | sort_by(-.dead)[:6][]
  | [ .path, .bytes, .dead ] | @tsv
' pricing.report.json

The result for the reproduction above is the treemap below. Six leaves account for the whole 214 KB, and 168.4 KB of it never executed once during the run.

Treemap of app.a91f.js: 214 KB shipped render-blocking, 168.4 KB never executed Six rectangles sized by resource bytes. Moment locale data at 92 kilobytes is entirely unused. Lodash at 41 kilobytes is 88 percent unused. The checkout route source at 28 kilobytes is unused on this page. The runtime and polyfills at 24 kilobytes, analytics at 18 kilobytes and the page header at 11 kilobytes are mostly executed. Unused portions are drawn in red, executed portions in green. app.a91f.js — 214 KB on the render-blocking path, 168.4 KB never executed unusedBytes (coverage at end of run) bytes executed during the run node_modules/moment/locale 92 KB, zero bytes executed 120 locale files, one is used at runtime node_modules/lodash 41 KB, 88% dead src/checkout 28 KB, unused on this route runtime + polyfills 24 KB, 12% dead src/analytics 18 KB, 44% dead src/header 11 KB Ranked by unusedBytes the cut list writes itself: locale data first, then the checkout route, then lodash.

Two of those leaves are pure configuration mistakes rather than code problems. The 92 KB of locale data is the classic bundler default that pulls every file under a package directory into the graph; the 28 KB of checkout source is on the pricing page only because the build produced one entry chunk. Neither needs a rewrite — both need a build config change, which is why the treemap is worth reading before anyone opens an editor.

Why the wastedMs column does not add up

The three blocking rows in this report sum to 860 ms of wastedMs, and the audit’s own details.overallSavingsMs says 640 ms. That gap is not a rounding error and not a Lighthouse bug; it falls out of how the counterfactuals are computed.

Each wastedMs is an independent simulation: Lantern takes the full graph, marks that one node non-render-blocking, re-runs the simulation, and reports the change in First Contentful Paint. Because the three requests are fetched concurrently over the same connection, deferring any one of them hands its bandwidth and its connection slots to the other two — which arrive sooner and take back part of the saving. overallSavingsMs is the joint counterfactual, produced by deferring all three in the same simulation, and it is the only number that predicts what shipping the whole change will do.

Simulated First Contentful Paint under each Lantern counterfactual Six horizontal bars. The baseline simulated First Contentful Paint is 2410 milliseconds. Deferring main.4b2c.css alone gives 2230, vendor.7c30.js alone gives 2142, app.a91f.js alone gives 1998, and deferring all three gives 1770 — a joint saving of 640 milliseconds. A sixth outlined bar shows the 1550 milliseconds that naively summing the three per-item savings would imply, which the simulation never produces. Simulated FCP under each counterfactual — the savings overlap Baseline (all three block) 2410 ms simulated FCP 2410 ms Defer main.4b2c.css wastedMs 180 2230 ms Defer vendor.7c30.js wastedMs 268 2142 ms Defer app.a91f.js wastedMs 412 1998 ms Defer all three together overallSavingsMs 640 1770 ms Naive sum of wastedMs what the item list implies 1550 ms The audit reports overallSavingsMs of 640 ms, not the 860 ms the three item rows sum to. All three share one connection, so deferring any one of them hands its bandwidth to the other two.

The same overlap explains a result that surprises people the first time they see it: cutting 168 KB out of a blocking bundle usually beats deferring the whole bundle, because deferral only moves the cost while deletion removes it from the connection entirely. Under a congested HTTP/2 stream schedule the deferred bundle still competes for bandwidth with the stylesheet that gates paint.

Classifying every node before you cut

Each leaf in the treemap belongs to exactly one of four categories, and each category has one correct remedy. Getting this wrong is how teams end up deferring a module the first paint depends on and shipping a blank hero instead of a faster one.

Which cut does each treemap node need? A three-question decision tree applied to the leaves of a render-blocking script. If the node is dead on every route, delete the dependency. If it is dead only on this route, split it behind a dynamic import. If it first executes after First Contentful Paint, move the script to defer. If none of those apply the module is needed before first paint and only that module should be inlined. Which cut does each treemap leaf need? 1 Dead on every route? unusedBytes = resourceBytes Delete the dependency moment/locale — 92 KB removed yes no 2 Dead on this route only? live on /checkout, not /pricing Split behind a dynamic import src/checkout — 28 KB off the path yes no 3 First runs after FCP? coverage snapshot at paint Move the script to defer src/analytics — 18 KB deferred yes no No to all three: this module really is needed before first paint src/header — 11 KB, inline this module alone and leave everything else deferred

Question 3 is the one Lighthouse cannot answer for you, because its coverage window closes at the end of the run rather than at paint. Snapshot coverage yourself:

// Coverage measured AT first paint, not at end of load. Lighthouse's unusedBytes counts a
// module as used if it ran at any point in the run, so anything scheduled after paint is
// invisible in the treemap — and those modules are precisely the ones that can be deferred
// without changing a single pixel of the first frame.
const page = await browser.newPage();
await page.coverage.startJSCoverage({ resetOnNavigation: false });
await page.goto('https://staging.example.com/pricing', { waitUntil: 'commit' });

// Resolve on the FCP paint entry rather than on a timeout: the whole question is which
// bytes executed before the paint that the scheduler is waiting on.
await page.evaluate(() => new Promise(resolve => {
  new PerformanceObserver((list, obs) => {
    if (list.getEntriesByName('first-contentful-paint').length) { obs.disconnect(); resolve(); }
  }).observe({ type: 'paint', buffered: true });
}));

const atPaint = await page.coverage.stopJSCoverage();
for (const { url, ranges, text } of atPaint) {
  const used = ranges.reduce((n, r) => n + r.end - r.start, 0);
  console.log(url, `${used} / ${text.length} bytes executed before FCP`);
}

Deterministic fix protocol

Steps 1 and 2 make the attribution possible; steps 3 to 5 make it correct; steps 6 to 8 make it stick.

  • [ ] 1. Make source maps reachable from the audited URL. Lighthouse fetches each //# sourceMappingURL target during the run. A map that is stripped at deploy, served from a bucket without permissive CORS, or gated behind basic auth causes script-treemap-data to emit one opaque node named after the script, with no children. If you cannot ship maps to production, audit a preview deployment built from the same commit that does.

  • [ ] 2. Keep the JSON report, not only the HTML one. The Treemap panel in the HTML report is a renderer over audits["script-treemap-data"]. Everything below reads the same data programmatically, which is what lets you diff two runs and fail a build.

  • [ ] 3. Join on the script URL and rank by dead bytes. Run the first jq pass above. A row with high wastedMs and low unusedBytes is a transfer problem — compress it, split it, or defer it. A row with high unusedBytes is a payload problem, and payload problems have cheaper fixes.

  • [ ] 4. Flatten the winner to leaves and take the top six. Parent nodes aggregate their children, so ranking on anything above a leaf double-counts. Six is usually enough: in the reproduction, the top three leaves are 84 % of all dead bytes.

  • [ ] 5. Re-measure coverage at First Contentful Paint. Use the observer snippet above. Anything that first executes after the paint entry can move to defer with no visual change, regardless of what the treemap says about it.

  • [ ] 6. Apply exactly one remedy per leaf, per the classification tree. Delete, route-split, defer, or inline — never two at once, or you cannot attribute the result. The locale case is a build config fix:

    // Deleting dead bytes beats deferring them: a deferred bundle still competes for the same
    // connection as the stylesheet that gates paint, whereas bytes that were never emitted
    // cannot contend for bandwidth at all.
    import { IgnorePlugin } from 'webpack';
    
    export default {
      plugins: [
        // moment resolves locales through a dynamic require the bundler cannot statically
        // narrow, so all 120 files land in the entry chunk. Ignoring the directory drops
        // 92 KB of source that no execution path on any route ever reaches.
        new IgnorePlugin({ resourceRegExp: /^\.\/locale$/, contextRegExp: /moment$/ })
      ]
    };
  • [ ] 7. Re-run and read overallSavingsMs, never the item sum. Compare the joint counterfactual before and after. If overallSavingsMs has fallen to zero but FCP has not moved, the blocking resources were never the constraint — check for a priority inversion on the critical stylesheet, covered in fixing low-priority critical CSS requests.

  • [ ] 8. Assert the budget in CI so the bytes cannot come back. A dead dependency removed in one sprint returns in the next as a transitive install. Assert on the audit, not on a bundle-size plugin, so the check tracks what actually blocks paint:

    # Assert on the JOINT saving. Asserting on a per-item wastedMs would let a regression hide
    # by splitting one blocking bundle into two smaller ones that each land under the limit.
    lhci autorun \
      --assert.assertions."render-blocking-resources".0=error \
      --assert.assertions."render-blocking-resources".1.maxNumericValue=150 \
      --assert.assertions."unused-javascript".1.maxNumericValue=40000

Before and after

Measured on the pricing route of a documentation site, desktop preset, five runs per side, median reported. The change set was steps 6 and 7 only: locale data ignored at build, lodash replaced with per-method imports, the checkout route split behind a dynamic import, and app.a91f.js moved to defer with src/header inlined.

Metric Before After Source
First Contentful Paint (simulated) 2 410 ms 1 180 ms audits.first-contentful-paint
Largest Contentful Paint (simulated) 3 240 ms 1 620 ms audits.largest-contentful-paint
render-blocking-resources.overallSavingsMs 640 ms 0 ms joint Lantern counterfactual
Blocking requests listed 3 0 details.items length
Blocking transfer bytes 398 KB 12 KB sum of item totalBytes
app.a91f.js resourceBytes 214 016 46 592 treemap root node
app.a91f.js unusedBytes 172 441 9 340 treemap root node
Dead bytes on the blocking path 168.4 KB 0 KB joined query, step 3
Performance score 46 91 categories.performance.score

The row that carries the argument is unusedBytes falling from 172 KB to 9 KB while resourceBytes fell to 46 KB. Had only the deferral shipped, resourceBytes would be unchanged and the dead code would still be on the connection, just later — which shows up as an LCP that barely moves even though FCP improves.

FAQ

Why is my render-blocking stylesheet missing from the Treemap?

Because the Treemap is JavaScript-only by construction. script-treemap-data is assembled from the Scripts, SourceMaps and JsUsage artifacts, all three of which collect script data exclusively, so a stylesheet that render-blocking-resources lists as costing 180 ms simply has no node. CSS coverage lives in the separate unused-css-rules audit, which reports one flat entry per stylesheet with a wastedBytes estimate — no nesting, no per-rule attribution. For blocking CSS the higher-leverage move is usually to make the sheet non-blocking at parse time rather than to shrink it; see eliminating render-blocking CSS with media queries.

The treemap shows one giant unnamed node instead of my modules. What is wrong?

The source map was not reachable. Lighthouse resolves the sourceMappingURL comment over the network during the run, from the same context as the page, and silently degrades to a single node named after the script URL when that fetch fails. The three usual causes are maps stripped by the deploy step, maps served from an asset origin without a permissive Access-Control-Allow-Origin, and maps behind the same authentication as a staging environment. A fourth, easy to miss: a bundler configured with sourcemap: 'hidden' emits the .map file but omits the comment, so nothing points at it.

Does a high unusedBytes value prove a module is safe to delete?

No, and treating it that way will break a route. Coverage is collected in a headless run with no interaction, so every modal, form validator and error path looks dead. The number is also uncompressed resource bytes, not transfer bytes, so a 92 KB dead node behind Brotli is perhaps 24 KB on the wire and the millisecond saving is proportionally smaller. Use unusedBytes to rank candidates, then confirm each one against your route graph — the deletion in step 6 was safe only because the locale directory is unreachable from every entry point, not because the treemap painted it red. When the module is reachable but late, the correct answer is a dynamic import, not deletion; the resulting request chain is covered in fixing dynamic import request waterfalls.