Every technique in this area presupposes a list. Consent categories are assigned per vendor; content security policies allowlist origins; budgets are attributed to owners; isolation decisions are made script by script. All of it depends on knowing what is actually on the page — and on most production sites, nobody does.

The reason is structural rather than negligent. Tags arrive through a tag manager without a commit, vendors load their own dependencies at runtime, marketing campaigns add pixels for a fortnight and never remove them, and the person who requested a given script left two years ago. A repository search finds the scripts your code loads; it does not find the ones your scripts load.

An inventory built from observed traffic rather than from source is the artefact that closes this gap. This guide covers how to collect one, what each entry needs to be useful, and how to detect the drift that will otherwise return the list to fiction within a quarter.


What an Inventory Entry Needs

A list of hostnames is not an inventory — it is a starting point that nobody can act on. Each entry needs four things beyond the origin, and the value of the whole exercise is in the fourth.

The observed cost: bytes transferred and main-thread time attributable to it, drawn from the same vendor attribution that feeds your RUM. The loading path: whether it was declared in your markup, injected by your code, or loaded by another vendor — the third case is the one that surprises people. The consent category it has been assigned, which links it to the gate. And an owner: a named team that will answer for it.

The owner field is what makes an inventory more than a report. A vendor with a cost and no owner cannot be removed, because nobody can say whether removal breaks something — so it stays, permanently, and the list grows. Requiring an owner at the point of addition is the only mechanism that reliably keeps the list from becoming a museum.

What one row has to carry A single inventory row expanded into five fields. The origin identifies the vendor. The observed cost records bytes and main-thread time from real traffic. The loading path records whether the script was declared in markup, injected by first-party code, or loaded by another vendor. The consent category links it to the gate. The owner names a team. A note records that a row without an owner cannot be acted on, so the vendor stays indefinitely. origin cdn.vendor.example what most "audits" stop at observed cost 142 KB · 88 ms from real traffic, not a lab run loading path in markup injected by us loaded by another vendor the third is the surprise consent category analytics links the row to the gate owner team: growth without this, nothing is removable The first two columns are measurement. The last three are what turn a report into a decision. Require an owner at the moment a vendor is added, when someone is present who knows the answer.

Collecting the List from Real Traffic

There are three collection surfaces and they see different things, which is why a serious inventory uses more than one. Each has a blind spot the others cover.

Resource timing from live sessions is the most representative: it observes what real visitors on real routes actually loaded, including vendors that only appear for some cohorts. Its blind spot is cross-origin entries without Timing-Allow-Origin, which report zero sizes — present, but uncosted.

A synthetic crawl with a headless browser sees every route you point it at, including ones with low traffic that field collection may never sample. Its blind spot is anything conditional on a real user: consent-gated tags, logged-in state, geography, and experiment variants.

Tag-manager configuration export is the only surface that shows tags which are configured but not currently firing — the ones waiting on a trigger that will eventually match. Its blind spot is everything the tags themselves load at runtime.

// collect-inventory.js — the field half. Ship with your RUM sampling.
function collectThirdParty() {
  const firstParty = location.host;
  const seen = new Map();

  for (const e of performance.getEntriesByType('resource')) {
    const host = new URL(e.name, location.href).host;
    if (host === firstParty) continue;

    const row = seen.get(host) || { host, bytes: 0, count: 0, uncosted: 0 };
    row.count += 1;
    // transferSize is 0 both for cache hits and for cross-origin responses
    // without Timing-Allow-Origin. Track those separately rather than as free.
    if (e.transferSize > 0) row.bytes += e.transferSize;
    else row.uncosted += 1;
    seen.set(host, row);
  }
  return [...seen.values()];
}
Three surfaces, three blind spots Field resource timing sees what real visitors loaded across cohorts, but cannot cost cross-origin responses that omit the Timing-Allow-Origin header. A synthetic crawl sees every route including low-traffic ones, but misses anything conditional on a real user such as consent-gated tags or logged-in state. A tag-manager configuration export sees tags that are configured but not yet firing, but misses everything those tags load at runtime. Together they cover each other's gaps. field resource timing sees: what real visitors loaded including cohort-specific tags misses: sizes for origins without Timing-Allow-Origin present but uncosted synthetic crawl sees: every route you point it at, including low-traffic ones misses: anything conditional on a real user — consent, login, geo a clean crawl proves little tag-manager export sees: tags configured but not yet firing — the pending list misses: everything those tags load at runtime configuration, not behaviour Each surface's blind spot is another surface's strength. One alone produces a confidently wrong list. Reconcile them on a schedule; the differences between them are usually the interesting findings.

Detecting Drift

A list is accurate on the day it is compiled. What keeps it accurate is a diff, run automatically, that reports three specific events: a new origin appearing, a known origin’s cost changing materially, and a known origin ceasing to appear.

The third is as valuable as the first. An origin that stops appearing is either a vendor that was removed and can be deleted from the allowlist, the consent categories and the policy — or a vendor that is silently failing to load. Both need a decision, and neither surfaces without the check.

// diff-inventory.js — run on a schedule, compare against the committed list.
export function diff(previous, current) {
  const before = new Map(previous.map((r) => [r.host, r]));
  const after = new Map(current.map((r) => [r.host, r]));

  const added = [...after.keys()].filter((h) => !before.has(h));
  const removed = [...before.keys()].filter((h) => !after.has(h));

  // Material change only — small variation is normal and should not alert.
  const grew = [...after.values()].filter((r) => {
    const was = before.get(r.host);
    return was && was.bytes > 0 && r.bytes / was.bytes > 1.25;
  });

  return { added, removed, grew };
}

Route the three categories differently. A new origin is a security and compliance event: it may be an unauthorised tag, and it certainly is not in the consent map or the CSP allowlist yet, so it should page someone. Growth is a performance event that belongs in a weekly review rather than an alert. Disappearance is a housekeeping event that should open a ticket to confirm and clean up.

Three kinds of drift, three responses A new origin appearing is a compliance and security event, because it is not yet in the consent map or the policy allowlist, and it should alert immediately. A known origin growing materially is a performance event and belongs in a weekly review. A known origin disappearing is a housekeeping event that should open a ticket, because it is either a removal to clean up after or a vendor that is silently failing. new origin not in the consent map not in the CSP allowlist alert immediately it is running ungated right now material growth a known vendor got heavier without any commit of yours weekly review alerting on this trains people to ignore it origin disappeared removed on purpose, or failing silently open a ticket clean up the allowlist either way Routing all three to the same channel guarantees the important one is lost among the routine ones. The committed list is the baseline, so every diff is also a reviewable change to a file.

From Inventory to Decision

A list that is never acted on decays into a report nobody opens, so it is worth being concrete about what the inventory is for. Three recurring decisions consume it, and each wants the data sliced differently.

The removal conversation. Sort by cost and read the top ten with their owners present. The question is not “is this vendor useful” — every vendor is useful to someone — but “is it worth what the median visitor pays for it”. Framing it as a trade with a named number changes the discussion: a team defending a 180 ms tag against a 200 ms budget is negotiating for a share of a fixed resource, not asserting a preference. Most sites find one or two vendors in the top ten that nobody actively wants, which is the cheapest performance work available anywhere.

The consolidation conversation. Group by what the vendor does rather than by who asked for it, and duplicates surface immediately: two analytics products, three tools that all record sessions, a tag manager loading a second tag manager. Duplication of this kind is nearly always organisational rather than technical — two teams each procured a tool for the same job — and the inventory is what makes it visible, because neither team can see the other’s line item any other way.

The onboarding gate. The most valuable use of the inventory is preventing additions rather than removing them. A change that adds a vendor should have to add a row: origin, category, owner, expected cost, and what it replaces. Requiring the row at the point of addition is a much lighter process than a review board, and it catches the two failure modes that matter — a vendor added with no owner, and a vendor added without anyone checking whether an existing one already does the job.

Each of these depends on the list being trusted, which in turn depends on it being generated rather than curated. The moment the inventory is something a person maintains by hand it starts to lag reality, and a list that is known to lag is a list that gets argued with rather than acted on. Keep the collection automatic, keep the annotations — owner, category, justification — in the same file under version control, and the artefact stays credible enough to make decisions from.

Three uses, three sorts The removal conversation sorts by observed cost and reads the top entries with owners present, framing the discussion as a trade against a fixed budget. The consolidation conversation groups by what each vendor does rather than by who requested it, which makes duplicate tooling visible across teams. The onboarding gate requires a new row before a vendor is added, which is a lighter process than a review board and catches unowned and duplicate additions. removal sort by cost, desc top ten, with owners present "worth what the median pays?" the cheapest work available consolidation group by function not by requesting team duplicates become visible usually an org problem, not a tech one onboarding gate a new row is required origin, category, owner, cost and what it replaces prevention beats removal All three depend on the list being generated. A hand-maintained inventory gets argued with rather than used.

Verification Checklist


Starting From Nothing

If no inventory exists today, the first version does not need to be complete to be useful. Collect a week of field resource timing, group by registered domain, sort by bytes, and take the top twenty. That list will already contain every vendor anyone argues about, and producing it takes an afternoon.

Resist the urge to make version one exhaustive. The trailing set of one-request origins — a font, a favicon service, a single pixel — is real but rarely decision-relevant, and chasing it delays the conversation the top twenty makes possible. Add the owner column by asking, in one message, who owns each of those twenty; the entries nobody claims are your first findings, before any measurement is interpreted at all.

Once the list exists and has been used once to make a decision, automating the collection becomes an easy sell, because the value is no longer hypothetical. Attempting the automation first, with no demonstrated use, is how inventory projects become dashboards nobody opens.

Interaction Matrix

Pattern How the inventory feeds it
Consent gating The category column is the gate’s configuration. A vendor absent from the inventory is a vendor absent from the gate.
Strict CSP The origin column generates the allowlist, and the diff keeps it from accumulating dead entries.
Subresource integrity The same registry holds the digests; the inventory records which vendors could not be pinned.
Budget enforcement Per-vendor budgets are derived from observed cost, and the owner column tells the gate whom to notify.
RUM attribution Shares the vendor registry outright — the attribution’s unmatched bucket is the inventory’s discovery queue.

Keeping the List Honest Over Time

Two habits keep an inventory from drifting back into fiction. The first is to store it in the repository rather than in a dashboard, so every change to it is a diff with an author and a review — which turns “a new vendor appeared” from a notification someone dismisses into a pull request someone approves.

The second is to record why each entry exists, in a sentence, next to the owner. Cost and category describe what a vendor does; the justification describes what it is for, and it is the only field that ages informatively. A justification that no longer reads as convincing two years later is the clearest signal available that a vendor has outlived its purpose, and it is a far easier conversation to have than one that starts from a number.

Neither habit costs anything at collection time, and both are difficult to add retrospectively — the justifications in particular, because the people who knew them have usually moved on by the time anyone wants them.

Troubleshooting

The field list and the crawl disagree substantially. Expected, and informative: the difference is usually consent-gated tags the crawl never triggers, plus experiment variants. Treat the union as the inventory and record which surface saw each entry.

Many origins report zero bytes. Those are cross-origin responses without Timing-Allow-Origin, not free resources. Request the header from the vendor; where it is not forthcoming, size them from a synthetic run and mark the figure as estimated.

The list churns constantly with CDN shard hostnames. Normalise before diffing: collapse a1.cdn.vendor.com and a2.cdn.vendor.com to the registered domain, and diff on the vendor key rather than the raw host.

A vendor appears only in some sessions. That is a real finding, not noise — it is geography, an experiment, or a consent branch. Segment the collection by those dimensions rather than aggregating them away.

Nobody claims an entry. Default to removal with a scheduled date, announced in advance. An unclaimed vendor that nobody misses after a fortnight has answered the ownership question.


Frequently Asked Questions

How is this different from Lighthouse's third-party summary?

The Lighthouse audit is a single synthetic run against one URL, which makes it an excellent starting point and a poor inventory. It cannot see consent-gated vendors, authenticated routes, regional variants, or anything that loads for a cohort it did not emulate — and it has no notion of ownership or of change over time.

Use it to bootstrap the list and to spot-check, then move to field collection for the version you act on. The two agreeing is reassuring; the two disagreeing is where the interesting vendors are.

How often should the inventory be reviewed by humans?

The automated diff runs continuously; the human review is for the questions a diff cannot answer — is this vendor still earning its cost, is the owner still the right owner, has the consent category drifted from what the vendor actually does now. Quarterly is a reasonable default, aligned with whatever privacy review you already run so it is one meeting rather than two.

Time-box it by reviewing the top entries by cost rather than the whole list. The trailing set of small vendors matters in aggregate but rarely repays individual attention, whereas the top five usually account for most of what a visitor pays.

Should the inventory be public?

A version of it usually already is, or should be: privacy regulations expect a cookie or vendor disclosure listing who receives data and why, and that disclosure is only accurate if it is derived from the same source as the technical configuration. Generating both from one registry is how they stay in agreement.

The public version is a subset — vendor names, purposes, retention — rather than the operational one with owners and byte counts. Keeping them in one file with a flag per field is simpler than maintaining two lists that will diverge.

What is a realistic number of third-party origins?

Fewer than you have. There is no correct figure, but the distribution is instructive: most sites can name three or four vendors they would defend, and most sites load considerably more than that. The gap between those two numbers is the inventory’s whole reason for existing.

Rather than targeting a count, target a property: every origin has an owner, a category, and a cost someone has looked at within the last quarter. Sites that hold that line tend to converge on a small number naturally, because each addition has to survive a conversation.


One closing observation: the inventory tends to be most valuable to the people who did not build it. Security teams, privacy counsel and procurement all need a current list of who receives data, and none of them has any other way to obtain one. Publishing it internally, on a schedule, converts an engineering artefact into a shared one — and makes the case for keeping it accurate far easier to sustain.

Up: Monitoring & Measuring Third-Party Performance