How to Measure Third-Party Cost with the Coverage Panel
This walkthrough belongs to Third-Party Script Performance inside JavaScript Bundle Optimization & Code Splitting. Removing a tag is a negotiation, and negotiations are won with numbers. "Third-party JavaScript is slow" loses; "this vendor ships 240KB of which 91% never executes, and costs 380ms of main-thread time on a mid-tier phone" wins.
The Coverage panel provides the first half of that sentence, and pairing it with main-thread attribution provides the rest.
Rapid Diagnosis: Record Coverage Over a Real Journey
- Open Coverage from the DevTools command menu (
Show Coverage), then start recording before a hard reload so start-up execution is captured. - Drive a realistic journey. Load, scroll, open a menu, submit a search. Coverage measures what executed, so a load-only recording overstates unused bytes for scripts that run on interaction.
- Read per-file, not per-total. The panel's summary mixes first- and third-party. Sort by URL and group by origin to see who owns what.
- Export before you close. Coverage data is lost on reload, so export the JSON immediately if you want to compare against a later run.
Root Cause Analysis: Why Coverage Alone Misleads
1. Unused bytes are not free, but they are not the whole cost either. Unexecuted code is still downloaded, parsed and compiled — roughly 1ms per KB on a mid-tier mobile CPU. But a small script that runs a 200ms loop costs far more than a large one that does nothing.
2. Coverage is journey-dependent. A script that only executes on checkout looks 95% unused on the homepage. That is a scoping finding — load it on checkout only — not a case for deletion.
3. Byte-range accounting differs from module accounting. Coverage reports executed byte ranges, so a large minified file where every function ran once may still show low coverage if only part of each function body executed.
4. It says nothing about ongoing interference. Long-lived observers, timers and delegated document listeners keep costing after start-up and never appear in a coverage number. Those are the ones that damage INP.
Step-by-Step Resolution: Build the Cost Table
1. Capture coverage per origin. Export the JSON and aggregate it — the panel's UI is per-file, and you need per-vendor.
// Paste in the console after exporting coverage JSON to a variable `cov`.
const byOrigin = {};
for (const entry of cov) {
const o = new URL(entry.url).hostname;
const total = entry.text.length;
const used = entry.ranges.reduce((n, r) => n + (r.end - r.start), 0);
byOrigin[o] ??= { total: 0, used: 0, files: 0 };
byOrigin[o].total += total; byOrigin[o].used += used; byOrigin[o].files++;
}
console.table(Object.entries(byOrigin).map(([origin, v]) => ({
origin, files: v.files, kb: Math.round(v.total / 1024),
usedPct: Math.round(v.used / v.total * 100)
})));
// trade-off: text.length counts characters, not transferred bytes, so it
// ignores compression. Use it to rank vendors relative to each other, and take
// absolute transfer sizes from the Network panel.
Expected outcome: a per-vendor table of shipped size and executed share.
2. Add main-thread time from the third-party summary. Coverage tells you what was shipped needlessly; this tells you what it cost to run.
npx lighthouse https://example.com --output=json --quiet \
--form-factor=mobile --throttling-method=simulate \
| jq -r '.audits["third-party-summary"].details.items[]
| [(.entity.text // .entity), (.mainThreadTime|round), .transferSize]
| @tsv'
# trade-off: entity matching credits a self-proxied vendor to your own origin.
# If you proxy tags through your domain, match by path prefix instead.
Expected outcome: two numbers per vendor that together describe the real cost.
3. Add the connection cost. Every distinct origin costs a DNS lookup and a TLS handshake — 100–300ms on mobile before any byte arrives. Count origins per vendor; a tag that loads from three domains is three handshakes.
4. Rank by cost per unit of value. Bring the table to the tag's owner with the question attached, not just the numbers.
| Vendor | Shipped | Executed | Main thread | Origins | Question for the owner |
|---|---|---|---|---|---|
| Tag container | 240KB | 9% | 380ms | 2 | Which tags inside are still used? |
| Chat widget | 180KB | 14% | 210ms | 1 | Can this become a facade? |
| A/B testing | 110KB | 31% | 160ms | 1 | Are any experiments running? |
| Consent manager | 45KB | 68% | 90ms | 1 | Can we self-host a minimal build? |
Expected outcome: a prioritised list where each row has an owner and a decision, rather than a general complaint about third parties.
Verification
Re-run the identical journey after each change and compare the table row by row. The three numbers should move in the expected directions: shipped bytes down when a tag is removed or scoped, main-thread time down when it is deferred or sandboxed, origins down when it is self-hosted.
Guard the result on a schedule, because container contents change without a deploy:
{ "ci": { "assert": { "assertions": {
"third-party-summary": ["error", { "maxNumericValue": 250 }],
"unused-javascript": ["warn", { "maxNumericValue": 200000 }],
"bootup-time": ["error", { "maxNumericValue": 1500 }]
} } } }
Run that against production nightly rather than only in pull requests — a tag added through a container never touches your repository, so a PR-only gate cannot see it. Pair it with a field check: INP segmented by whether the session received the tag will tell you within a day whether a new addition hurt.
FAQ
Does high unused-byte coverage mean I should remove the script?
Not by itself. Low coverage means the integration is heavier than the job requires — often because the vendor ships one SDK for every feature. The right conclusion is usually a lighter integration: a scoped build, loading it only on the routes that use it, or a facade. Removal is justified when coverage is low and nobody can name what the tag is for, which is more common than it sounds.
Why does coverage change so much between runs?
Because it measures execution, and execution depends on what you did. A load-only run shows interaction handlers as unused; a run where you opened the chat widget shows most of it executing. Standardise on one scripted journey and compare only like with like, otherwise you will chase differences that are entirely your own navigation.
Can I automate coverage collection in CI?
Yes — drive a headless browser through your standard journey with the coverage API enabled and write the per-origin aggregate to a file. That gives you a trend rather than a snapshot, which is far more persuasive: a chart showing a vendor's shipped bytes growing 40% over two quarters ends the conversation faster than any single measurement.
How do I present this to a non-engineering stakeholder?
Convert milliseconds into the thing they already track. Main-thread time on a mid-tier phone maps to interaction latency, and interaction latency maps to bounce and conversion in every published study and probably in your own funnel data. A row that reads "this tag costs 380ms of interaction delay on mobile, where 62% of our sessions are" is a business statement. A row that reads "240KB, 9% coverage" is a technical one, and it will lose to the argument that the tag is needed.
Does the Coverage panel work for scripts loaded in an iframe?
It reports the top-level document's resources, so a script running inside a cross-origin iframe will not appear. That is a real blind spot for embeds, which is why the facade approach is evaluated separately by measuring the page with and without the embed present. Where a vendor loads its heavy code inside its own frame, measure the difference it makes to your page rather than trying to attribute bytes you cannot see.
What is a reasonable third-party budget?
Set it from your own baseline rather than an industry figure: measure today's total, agree a number below it, and ratchet down as tags are removed. As a starting point, keeping third-party main-thread time under about 250ms on a mobile profile leaves room for your own code inside a total-blocking-time budget that still passes. The value matters less than the ratchet — a budget that only ever moves downward is what actually changes behaviour.
Related
- Third-Party Script Performance — what to do once the cost table exists.
- Replacing YouTube embeds with a facade — the standard remedy for a heavy, rarely used embed.
- How to configure webpack-bundle-analyzer for production — the equivalent accounting for your own bundle.