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”.
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.
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.
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.
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
//# sourceMappingURLtarget during the run. A map that is stripped at deploy, served from a bucket without permissive CORS, or gated behind basic auth causesscript-treemap-datato 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
jqpass above. A row with highwastedMsand lowunusedBytesis a transfer problem — compress it, split it, or defer it. A row with highunusedBytesis 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
deferwith 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. IfoverallSavingsMshas 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.
Related
- Render-Blocking Resource Identification ↑ — parent topic: how the browser decides a resource blocks paint, and the full identification workflow this audit plugs into
- Eliminating Render-Blocking CSS with Media Queries — sibling: the stylesheet half of the problem, which the treemap cannot see
- Fixing Low-Priority Critical CSS Requests — when the blocking list is clean but paint is still late
- Decoding the Chrome DevTools Network Waterfall — verifying a Lantern counterfactual against an observed load