Auditing Third-Party Scripts With Lighthouse

You cannot budget what you have not attributed. "The page is slow" becomes actionable the moment you can say "the chat widget is 340 ms of the 940 ms of main-thread time, and the tag manager is another 120 ms with an empty container". Lighthouse produces exactly that attribution, and it can run in CI so the numbers do not drift back.

This guide runs the audit end to end: the third-party summary for attribution, the treemap for what is inside each bundle, a long-task trace for costs that appear after load, and a CI gate that fails the build when the total crosses a budget. It is the measurement companion to Third-Party Script Performance on Static Sites.

Prerequisites

  • Node 20+ with lighthouse available via npx, or Chrome's built-in Lighthouse panel for interactive runs.
  • A preview URL per pull request — the pattern in Preview Environments for Pull Requests is what makes per-PR auditing possible.
  • A representative page: whichever template carries the most third-party code, not the homepage by default.

Step 1 — Attribute the Cost by Entity

The third-party-summary audit groups every request by the entity that owns it and reports both transfer size and main-thread blocking time:

npx lighthouse https://preview.example.com/docs/deploying/ \
  --form-factor=mobile --throttling-method=simulate \
  --only-audits=third-party-summary,total-byte-weight,bootup-time \
  --output=json --output-path=./lh.json --quiet

jq -r '.audits["third-party-summary"].details.items[]
       | [.entity, (.transferSize/1024|floor|tostring + " KB"),
          (.blockingTime|floor|tostring + " ms")] | @tsv' lh.json

Sort the output by blocking time, not by size. The ranking almost always differs, and blocking time is what a reader feels.

Run it on mobile with simulated throttling even if your audience skews desktop. The mobile profile models a mid-tier device, and third-party code is precisely the category whose cost scales with how slow the CPU is: a bundle that parses and executes in 40 ms on a laptop routinely takes 300 ms on a phone from three years ago. Auditing on desktop hardware produces a report where every vendor looks affordable.

Ranking by bytes versus ranking by blocking time Two ranked lists of the same five vendors. Ranked by transfer size the video player leads with 780 kilobytes, followed by the chat widget, consent platform, tag manager and analytics. Ranked by blocking time the chat widget leads with 340 milliseconds, ahead of the video player, consent platform, tag manager and analytics. The two rankings disagree — fix the right one By transfer size video player · 780 KB chat widget · 96 KB consent · 58 KB tag manager · 34 KB analytics · 6 KB By blocking time chat widget · 340 ms video player · 310 ms consent · 190 ms tag manager · 120 ms analytics · 18 ms The 96 KB widget costs more main-thread time than the 780 KB player, because bytes are not work
Ranking by size sends you after the video first; ranking by blocking time sends you after the widget, which is the change readers actually feel on a mid-tier phone.

Step 2 — Open the Treemap for What Is Inside

The treemap (--view in the CLI, or the Treemap link in the HTML report) breaks each bundle into modules and marks unused bytes. It answers a different question from the summary: not "who is expensive" but "what part of this is expensive, and are we even using it".

npx lighthouse https://preview.example.com/docs/deploying/ \
  --form-factor=mobile --output=html --output-path=./lh.html --view

Two patterns show up constantly on content sites. A vendor bundle that is 70-80% unused, because the site enables one of its twelve features — a candidate for a lighter alternative or a self-hosted replacement. And a polyfill bundle shipped to every browser to satisfy one that nobody in the analytics uses any more.

Step 3 — Trace the Costs That Appear After Load

Lighthouse stops recording after the run, so anything periodic is invisible to it. Record a Performance trace with the page idle for thirty seconds and read the Bottom-Up view grouped by URL, or collect it programmatically:

// Long tasks with attribution, logged for 30 s
const seen = new Map();
new PerformanceObserver((list) => {
  for (const t of list.getEntries()) {
    if (t.duration < 50) continue;
    const src = t.attribution?.[0]?.containerSrc || t.attribution?.[0]?.name || 'unknown';
    seen.set(src, (seen.get(src) || 0) + t.duration);
  }
}).observe({ type: 'longtask', buffered: true });
setTimeout(() => console.table([...seen].map(([src, ms]) => ({ src, ms: Math.round(ms) }))), 30000);

Anything that appears here while the page is idle is a recurring cost paid for the whole session — a presence poll, a heartbeat, a re-render on focus. These are the costs that damage Interaction to Next Paint without touching any load metric.

Step 4 — Gate the Budget in CI

An audit that runs once is a snapshot; an audit that runs on every pull request is a ratchet. Lighthouse CI's assertions read the same audits:

{
  "ci": {
    "collect": { "url": ["https://preview.example.com/docs/deploying/"], "numberOfRuns": 3 },
    "assert": {
      "assertions": {
        "third-party-summary": ["error", { "maxNumericValue": 150 }],
        "total-byte-weight": ["error", { "maxNumericValue": 512000 }],
        "interactive": ["warn", { "maxNumericValue": 3500 }]
      }
    }
  }
}
# .github/workflows/perf.yml
- name: Lighthouse budget
  run: npx --yes @lhci/cli autorun --config=./lighthouserc.json

Three runs and a median matter here: a single Lighthouse run on a shared CI runner varies enough to fail a passing build. The same discipline applies to any performance gate, as covered in Measuring Build-Time Regressions in CI.

Step 5 — Turn the Report Into an Owned List

A report nobody owns changes nothing. Convert the ranked output into a table in the repository, one row per entity, with an owner and a decision. The decision column has only four values, and forcing every vendor into one of them is what makes the audit finish rather than repeat.

Four decisions available for each third party A two by two matrix with reader value on the vertical axis and main-thread cost on the horizontal axis. High value and low cost means keep. High value and high cost means gate behind an interaction. Low value and low cost means keep but review. Low value and high cost means remove. Value to the reader against cost to the reader Keep as is self-hosted vitals beacon 22 ms · high value Gate behind intent chat widget, video player valued by few, paid by all Keep, review yearly small marketing pixel cheap, unclear value Remove empty tag manager 120 ms for nothing high value low value low cost high cost
Most third parties that survive an audit land in the top-right box, and gating is what moves them out of the load path without starting a negotiation about removing them.

The bottom-right quadrant is the easiest and most contested. An empty tag manager container costing 120 ms is trivially removable in engineering terms and often defended on the grounds that someone might need it later. Bring the number: 120 ms on every page view, for a capability nobody has used in eleven months, is a concrete trade rather than a preference. Keeping a record of when each vendor was last actually used makes that conversation short.

Re-run the audit after each change and update the row. The list is finished when every entity has an owner, a decision and a measured cost — not when the score reaches a particular number.

Measured Impact

Running the audit on a documentation site's article template and acting on the top two rows:

StageThird-party blockingTransferINPLighthouse perf
Before the audit760 ms1.28 MB240 ms61
Chat widget interaction-gated420 ms1.18 MB165 ms74
Video replaced with a facade110 ms430 KB130 ms92
Tag manager removed, analytics self-hosted22 ms380 KB120 ms97
Third-party blocking time across four audit actions A descending bar chart. Third-party blocking time falls from 760 milliseconds before the audit, to 420 after gating the chat widget, to 110 after the video facade, to 22 after removing the tag manager. A dashed line marks the 150 millisecond budget, crossed at the third step. Two changes did 85% of the work 150 ms budget 760 ms 420 ms 110 ms 22 ms before chat gated video facade tags removed Median of 3 Lighthouse runs, mobile profile with simulated throttling
The audit's value is the ordering: without attribution, teams typically start with the analytics script, which was 18 ms of the original 760.

Pitfalls & Rollback

  • Auditing the homepage only. Article and documentation templates usually carry more third-party code than the marketing front page.
  • Single-run assertions. Lighthouse on a shared runner is noisy; use three runs and the median or you will chase phantom regressions.
  • Chasing the composite score. It hides which metric moved. Assert on the underlying numbers.
  • Ignoring the tag manager container. Its own bundle may be small while the tags inside it are not — audit production, where the real container is live.
  • Auditing a warm cache. Run with a fresh profile; a cached vendor bundle understates the first-visit cost that new readers pay.
  • Rollback: the audit changes nothing by itself. The CI assertion is one config file — lowering the threshold or removing the assertion restores the previous pipeline immediately.

Conclusion

Attribution turns an argument about performance into a ranked list. Run the third-party summary against the template that carries the most vendor code, sort by blocking time, use the treemap to see what is inside the worst offenders, trace an idle page for recurring costs, and then freeze the result with a Lighthouse CI assertion. The fixes themselves — gating, facades, self-hosting — are covered in Third-Party Script Performance on Static Sites.

FAQ

What is the difference between transfer size and blocking time in the report?

Transfer size is what came down the wire; blocking time is how long the main thread was unavailable to respond to input because that script was executing. They correlate loosely at best — a small script that builds UI can block for longer than a large one that only sends a request.

Why does the treemap show different numbers from the network panel?

The treemap reports unminified script bytes and, when source maps are available, attributes them to modules, so it answers "what is inside this bundle". The network panel reports compressed transfer. Use the treemap to find unused code and the network panel to budget bandwidth.

Should I audit a preview deploy or production?

Both, for different reasons. Audit a preview deploy on every pull request to catch regressions before they ship. Audit production monthly, because tag managers and vendor updates change what runs without any change to your repository.

How do I attribute a long task to a specific vendor?

Record a Performance trace, open the Bottom-Up view and group by URL. Anything attributed to a script whose origin is not yours is third-party work, and the attribution field on long-task entries gives you the same mapping programmatically.

Is a Lighthouse score a good performance budget?

No. The score is a weighted composite that hides which metric moved, and it is noisy run to run. Budget the underlying numbers you care about — third-party blocking time, transfer size, LCP and INP — and treat the score as a headline only.