Your per-vendor cost table reports a tag at 40 ms of main-thread time, the number looks acceptable against a 100 ms budget, and Core Web Vitals still reports a failing Interaction to Next Paint that nobody can attribute.
Triage: Look at the Distribution, Not the Mean
- Pull the raw per-vendor durations for one week rather than the aggregate your dashboard shows.
- Compute the median, the 75th and the 95th percentile for a single vendor.
- Compare the 75th against the mean. A 75th percentile several times the mean indicates a heavy upper range the average is hiding.
- Group the same data by
navigator.hardwareConcurrencyor by device memory and compare the groups.
Root Cause: Cost Is a Function of Device, and the Mix Is Not Uniform
Main-thread cost scales with device capability far more sharply than transfer size does. A script that parses and executes in 18 ms on a recent laptop can take ten times that on a mid-range Android device several years old, because parse, compile and execution are all CPU-bound and the gap between the fastest and slowest devices in real traffic is wider than most engineers’ intuition allows.
Averaging across that distribution produces a number that describes nobody. Worse, it is a number that moves with your traffic mix rather than with your code: a marketing campaign that brings more mobile visitors will raise your “vendor cost” without anything about the vendor changing, and a desktop-heavy week will lower it. Teams then chase a metric that is reporting demographics.
The Core Web Vitals thresholds are defined at the 75th percentile precisely to avoid this, which creates an unhelpful asymmetry: you are scored on a percentile and budgeting against a mean. Aligning the two — measuring, budgeting and alerting at p75, segmented by device class — is the whole intervention, and it usually reveals that one or two vendors account for most of the failing cohort’s cost.
Resolution: Segment at Collection, Aggregate at p75
// Attach a device class to every sample, at collection time.
function deviceClass() {
const cores = navigator.hardwareConcurrency ?? 0;
const mem = navigator.deviceMemory ?? 0; // GB, coarse and Chromium-only
if (cores >= 8 && mem >= 8) return 'high';
if (cores >= 4 && mem >= 4) return 'mid';
if (cores > 0 || mem > 0) return 'low';
return 'unknown'; // never merge this into a class
}
function sample(vendor, duration) {
return { vendor, duration, device: deviceClass(), ts: Date.now() };
}
-- Aggregate at the percentile you are scored on, within each class.
SELECT vendor,
device,
COUNT(*) AS n,
APPROX_QUANTILES(duration, 100)[OFFSET(75)] AS p75_ms
FROM vendor_samples
WHERE ts > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY vendor, device
HAVING n > 500 -- below this the percentile is noise, not a measurement
ORDER BY p75_ms DESC;
Keeping unknown as its own class rather than folding it into a default is worth the extra row. The hints are Chromium-only, so unknown is largely Safari and Firefox traffic, and merging it into mid quietly attributes an entire browser population to a capability band nobody measured.
The HAVING clause matters as much as the percentile. A p75 computed over forty samples is not a percentile in any useful sense, and a dashboard that shows one per device class per vendor per day will be dominated by cells with too little data — which is how teams conclude that the metric is noisy and stop looking at it.
Verification: The Failing Cohort Becomes Visible
Re-run the aggregation and read the low and unknown rows. They should show materially higher figures than the overall average you started with, and the vendors at the top of that segment should be the ones your INP attribution has been pointing at. If the segments look identical, either the classification is not working or the cost genuinely is device-independent — which is possible for a pure network cost and rare for a script.
Budgeting Against the Segment That Matters
Once the table exists, the budget should follow it. A single site-wide ceiling per vendor is a compromise between cohorts that experience wildly different costs, and in practice it is set by whoever argues most persuasively rather than by the population it protects.
Set the ceiling against the low class, and treat the high class figure as informational. That inverts the usual dynamic in a useful way: a vendor that is cheap on fast devices and expensive on slow ones now fails the budget, which is the correct outcome, because the visitors on slow devices are the ones whose experience determines your field metrics and, frequently, your conversion rate.
It also changes what optimisation looks like. Against an averaged budget, the obvious lever is reducing bytes; against a low-class budget, the lever is reducing work — deferring execution, splitting a bundle, or moving computation off the main thread — because that is what the constrained cohort is actually short of. The two lead to different changes, and only one of them moves the metric you are scored on.
Common Pitfalls
- Merging
unknowninto a class. It is largely a browser population, not a capability band, and merging it distorts both. - Segmenting after aggregation. A mean cannot be decomposed into per-segment percentiles afterwards. The device class has to be on the sample.
- Using screen size as a proxy for capability. A large tablet can be slower than a small phone; use CPU and memory hints where available, and accept
unknownwhere not. - Displaying thin cells. A percentile over a few dozen samples looks authoritative and is not.
Frequently Asked Questions
Are hardwareConcurrency and deviceMemory reliable enough?
They are coarse, capped, and available only in Chromium — and they are still far better than nothing for this purpose. deviceMemory is rounded to a small set of values and hardwareConcurrency reports logical cores rather than performance, so neither is a benchmark; what they give you is a stable ordering, which is all a segmentation needs.
For the browsers that expose neither, the honest approach is a separate unknown bucket rather than an inference. Some teams supplement with a short synthetic benchmark at first load, which is more accurate and costs main-thread time on exactly the devices that can least afford it — a trade that is rarely worth making for a monitoring refinement.
How many classes should we use?
Three plus unknown is usually right. Two hides too much — the interesting distinction is between mid-range and genuinely slow devices, and a binary split puts them together — while five or more fragments the sample count until several cells are too thin to read.
Whatever you choose, keep the boundaries fixed. Re-tuning the thresholds changes every historical figure and destroys your ability to compare across time, which is most of the value once the table has been running for a few months.
Should the segmentation include network as well as device?
For transfer cost, yes; for main-thread cost, it adds little. The two are largely independent — a fast phone on a poor connection has a network problem, and a slow phone on fibre has a CPU problem — so a single combined class conflates two different remedies.
Where you have the volume, segmenting by both and reading them as a grid is genuinely informative: it separates the vendors you should defer because they are heavy to download from the ones you should move off the main thread because they are heavy to run. Where you do not, segment by device for execution cost and leave network to your transfer-size budgets.
Does the same segmentation apply to transfer size?
It applies, and it matters much less. Bytes are bytes regardless of the device that receives them, so a vendor’s transfer size is roughly constant across capability classes and the segmented table will show four similar numbers. That is a useful null result: it confirms the classification is working and tells you the cost is network-bound rather than CPU-bound.
Where segmentation does help on the network side is by connection rather than by device. The same payload costs very different amounts of time on a fast fixed line and a congested mobile connection, and that distinction points at different remedies — fewer bytes and better caching for the first, deferral and origin consolidation for the second.