Device & Network Segmentation
An origin p75 is a weighted average of populations that have nothing in common. As established in Core Web Vitals & Performance Metrics Fundamentals, the assessed number is the 75th percentile across all page loads — so an origin whose traffic is 70% desktop can sit comfortably inside Good while every mobile user on a mid-tier Android device is in the Poor band. This page covers the signals available to classify a session, how to build device and network cohorts that survive statistical scrutiny, and which divergences between cohorts actually indicate a bug rather than physics.
Segmentation is not a reporting nicety. It is the difference between “our LCP is 2.4 s” and “our LCP is 1.6 s on desktop and 4.1 s on the third of our traffic that converts at half the rate.” Only the second sentence produces an engineering decision.
What you can actually observe from the browser
Device classification in the field is inference, not measurement. Four signals are available without asking permission, and each is wrong in a specific way.
| Signal | API | What it tells you | Where it lies |
|---|---|---|---|
| Logical cores | navigator.hardwareConcurrency |
Rough CPU parallelism | Capped and quantised by some browsers; ignores core quality |
| Device memory | navigator.deviceMemory |
Rounded RAM in GiB | Quantised to 0.25/0.5/1/2/4/8; absent on Safari |
| Effective connection | navigator.connection.effectiveType |
Estimated round-trip class | A rolling estimate, not the current link; Chromium only |
| Save-Data | navigator.connection.saveData |
Explicit user preference | Only set when the user opted in |
None of these is a device model, and you should not try to derive one. What works is a coarse tier — three or four buckets — that correlates with the metric you care about and stays stable as devices age.
// device-tier.js — a coarse, stable classification. Three buckets, not twelve.
export function deviceTier() {
const cores = navigator.hardwareConcurrency || 0;
const memory = navigator.deviceMemory || 0;
// Safari reports neither; fall back to a screen-size heuristic so those
// sessions land in a named bucket rather than "unknown".
if (!cores && !memory) {
return Math.min(screen.width, screen.height) >= 768 ? 'unknown-large' : 'unknown-small';
}
if (cores >= 8 && memory >= 8) return 'high';
if (cores >= 4 && memory >= 4) return 'mid';
return 'low';
}
export function networkTier() {
const c = navigator.connection;
if (!c) return 'unknown';
if (c.saveData) return 'save-data';
return c.effectiveType || 'unknown';
}
Send both on every beacon alongside the metric, as described in Self-Hosted Beacon Collection. Classifying at query time from a user-agent string is always worse: the UA tells you the browser, not the silicon, and UA reduction is steadily removing what little device information it carried.
Threshold configuration per cohort
The assessed thresholds are global — LCP ≤2.5 s, INP ≤200 ms, CLS ≤0.1 at p75 — and they do not bend for a slow device. What changes per cohort is the engineering response when a band is missed.
| Cohort | p75 LCP | Band | What the number is telling you |
|---|---|---|---|
| Desktop, wired | ≤ 1.8 s | Good | Server and cache are healthy; regressions here are code |
| High-tier mobile, 4G | ≤ 2.5 s | Good | Payload is proportionate; watch third parties |
| Mid-tier mobile, 4G | 2.5–4.0 s | Needs improvement | CPU-bound: decode, parse and hydration dominate |
| Low-tier mobile, 3G | > 4.0 s | Poor | Bandwidth-bound: total bytes is the only lever left |
The diagnostic value comes from the gap between cohorts, not the absolute numbers. A mid-tier device that is 1.6× slower than desktop is normal physics. A mid-tier device that is 3× slower is carrying work that should not be on the main thread at all.
Measurement implementation
The cohort keys have to be on the beacon at collection time, and they have to be low cardinality. Three device tiers × five network types × a country code is 750 buckets before you add a route — already at the edge of what produces stable percentiles at moderate traffic.
// cohort.js — assembled once per page load and attached to every beacon.
import { deviceTier, networkTier } from './device-tier.js';
const cohort = {
device: deviceTier(),
network: networkTier(),
// Coarse geography only: the collector derives this from the edge, never
// from a raw IP stored in your warehouse.
viewport: window.innerWidth < 600 ? 'sm' : window.innerWidth < 1024 ? 'md' : 'lg',
// A stable release marker lets you compare cohorts across deploys.
release: document.documentElement.dataset.release || 'unknown',
};
export function withCohort(metric) {
return { ...metric, ...cohort };
}
Two rules keep this data usable. Compute the cohort once, at page load, and reuse it — effectiveType changes mid-session and a metric tagged with a different network than the one it was measured on is worse than untagged. And never let a cohort key be free-form: an allowlist of accepted values at the collector stops a client bug from creating ten thousand new buckets overnight.
Sample-size floors
A percentile computed over too few samples is noise wearing a decimal point. Before publishing any cohort number, check it clears a floor.
| Samples in the cohort | p75 usable? | What it is good for |
|---|---|---|
| < 100 | No | Nothing — do not display it |
| 100–1,000 | Directionally | Spotting an order-of-magnitude problem |
| 1,000–10,000 | Yes | Tracking a cohort week over week |
| > 10,000 | Yes | Detecting a 5% regression with confidence |
The arithmetic behind those bands is in Calculating Confidence Intervals for a Sampled p75. The practical consequence is that deep segmentation and aggressive sampling are in direct tension: a 1% sample that gives a fine origin p75 leaves your low-tier Android cohort with forty samples a day. Stratified oversampling of the cohorts you cannot afford to lose is the standard answer, and it is covered in RUM Data Sampling Strategies.
Step-by-step debugging workflow
- Split the origin p75 by device tier first. It is the segmentation with the largest effect size, and it takes one
GROUP BY. - Compute the ratio between tiers, not just the values. Ratios are stable across releases; absolute values move with traffic mix.
- Check whether the gap is CPU or bandwidth by comparing the LCP sub-parts. A load-duration-dominated gap is bytes; a render-delay-dominated gap is CPU. The split comes from the attribution build.
- Cross-cut with route. Device gaps are rarely uniform: one template usually accounts for most of the divergence.
- Reproduce with matched throttling — 4× CPU and a Fast 3G profile approximates the mid-tier cohort closely enough to iterate against.
- Verify the fix in the cohort that motivated it, not in the origin aggregate, which will barely move.
Field-data analysis patterns
- Device tier × route: finds screens whose cost is disproportionate on weak CPUs — usually heavy hydration or large lists.
- Network tier × LCP sub-part: separates “the file is too big” from “we asked for it too late”.
- Country × network tier: distinguishes an infrastructure gap (no edge presence) from a device-mix difference. Both look identical in a country-only breakdown.
- Save-Data × everything: users who opted into data saving are self-selected for constrained conditions and are an excellent early-warning cohort.
- Viewport × CLS: layout shift is frequently viewport-specific, because the breakpoint that reflows is the one that shifts.
Optimization strategies that are cohort-specific
Adaptive loading, gated on real signals. Serve fewer images, skip non-essential animations and defer secondary bundles when saveData is set or effectiveType is 2g/slow-2g. This is measurable in the field within days because the affected cohort is small and its metrics are extreme.
Ship less JavaScript to weak CPUs, not different JavaScript. Conditional bundles by device tier create a testing matrix nobody maintains. Reducing the baseline bundle helps every cohort and helps the weakest one most.
Prioritise decode cost, not just transfer size. A 200 KB AVIF decodes measurably slower on a low-tier device than a 260 KB WebP. On desktop the difference is invisible; on the cohort that is failing it is 80 ms. Format choice belongs to Choosing LCP Image Formats, Sizes and srcset.
Fix the tail with a floor, not an average. Setting an internal target of “no cohort above 4 s” produces different work than “origin p75 below 2.5 s”, and it is the one that moves the assessment.
Validating that your tiers mean anything
A classifier that does not correlate with performance is worse than no classifier, because it launders a guess into a dimension people trust. Two checks are enough to keep it honest, and both should run monthly rather than once.
Separation. Compute the p75 of a load metric within each tier. Adjacent tiers should differ by a margin larger than the noise in either. If mid and high sit within 10% of each other, the boundary between them is not carrying information and the two should be merged. A three-tier split that produces 1.6 s, 2.4 s and 4.2 s is doing real work; one that produces 2.1 s, 2.3 s and 2.4 s is not.
Stability. The share of traffic in each tier should move slowly. A tier whose population swings 15% week to week is usually picking up a browser behaviour change rather than a change in your audience — Chromium capping hardwareConcurrency, or a new browser version reporting deviceMemory differently. Chart the tier shares next to the tier values; a value that moves while the share moves is a composition change, not a regression.
-- Monthly classifier health: separation and stability in one result set.
SELECT
device_tier,
count() AS sessions,
count() / sum(count()) OVER () AS share,
quantileTDigest(0.75)(value) AS p75_lcp,
quantileTDigest(0.75)(value) /
first_value(quantileTDigest(0.75)(value)) OVER (ORDER BY device_tier) AS ratio_to_first
FROM rum_events
WHERE metric = 'LCP' AND ts >= now() - INTERVAL 30 DAY
GROUP BY device_tier
ORDER BY p75_lcp;
The third useful output of this query is the unknown share. Treat anything above about 20% as a reason to improve the fallback rather than a reason to accept the gap — a fifth of your traffic sitting outside every cohort makes every cohort comparison a statement about the remainder.
Geography, and what it actually proxies for
Country is the most requested segmentation and the most frequently misread. A country breakdown mixes three independent things: distance to your infrastructure, the local network quality, and the local device mix. All three are real; only the first is something your CDN configuration can change.
| Pattern in the data | Most likely cause | First action |
|---|---|---|
| High TTFB, normal LCP-after-TTFB | Distance to origin or a cache miss | Edge presence, cache policy |
| Normal TTFB, high load duration | Local bandwidth | Byte budget, adaptive loading |
| Normal TTFB, high render delay | Device mix skewed low | Main-thread work, less JavaScript |
| Everything high | All three | Segment further before acting |
Deriving the country at the edge, from the request rather than from a stored IP address, keeps this dimension available without holding an identifier you would rather not hold — the pattern is covered in Privacy-Compliant Tracking. Country-level cohorts also hit the sample floor quickly on all but the largest sites; grouping into regions is usually the difference between a stable series and a chart that jumps every Monday.
Failure modes and gotchas
- Safari sessions falling into
unknown. Safari exposes neitherdeviceMemorynorhardwareConcurrencyreliably. Ifunknownis 30% of your traffic, every cohort comparison is drawn from the other 70% and is not representative. effectiveTypeis a rolling estimate. It reflects recent throughput, not the current connection, and it updates asynchronously. A session that starts on WiFi and moves to cellular carries the wrong label unless you re-read it — which you should not do mid-session, for the reason above.- Cohort keys computed at query time. Deriving device tier from a stored user-agent string produces a classification that changes retroactively every time you tweak the parser. Classify once, at collection.
- Comparing cohorts across different traffic mixes. A marketing campaign that brings low-tier traffic will move the origin p75 with no code change at all. Always check cohort volumes alongside cohort values before declaring a regression.
- Over-segmentation. Device × network × country × route × release is thousands of buckets, almost all of them below the sample floor. Add one dimension at a time and check the volume column.
- Treating
unknownas a cohort. It is a mixture of everything the classifier failed on. Report it, never optimise for it.
CI/CD integration
Cohorts do not exist in CI — there is one runner with one CPU profile. What you can gate is the proxy: run the lab suite under a throttling profile calibrated to your mid-tier cohort, and fail the build against a budget derived from that cohort’s field data rather than from the desktop number.
// lighthouse-budget.js — throttle to approximate the mid-tier field cohort.
module.exports = {
ci: {
collect: {
numberOfRuns: 5,
settings: {
// 4x CPU slowdown and a Fast 3G-like link tracks the mid-tier
// Android cohort within about 15% on this origin. Re-calibrate
// quarterly against the field p75, not once.
throttling: {
cpuSlowdownMultiplier: 4,
rttMs: 150,
throughputKbps: 1638,
},
},
},
assert: {
assertions: {
'largest-contentful-paint': ['error', { maxNumericValue: 3800 }],
'total-blocking-time': ['error', { maxNumericValue: 350 }],
},
},
},
};
Re-derive the multiplier against field data every quarter. A throttling profile that matched your device mix two years ago is now describing a cohort that has moved on.
FAQ
Why does my origin pass Core Web Vitals while half my users are in the Poor band?
Because p75 is computed across all loads. If 70% of your traffic is fast desktop, the 75th percentile can sit inside the Good band while the entire mobile population fails. Segment before you conclude anything from the aggregate.
Is navigator.deviceMemory reliable enough to classify on?
It is quantised and missing on Safari, so it is unreliable as a measurement and adequate as a bucket boundary. Combine it with hardwareConcurrency, keep the tiers coarse, and treat the result as a correlate of performance rather than a device fact.
Should I serve different code to low-tier devices?
Prefer serving less code to everyone. Conditional bundles multiply your test matrix and tend to rot. Adaptive loading of non-essential assets — images, animations, secondary widgets — carries far less maintenance risk than forking the application bundle.
How many samples does a cohort need before I can trust its p75?
About a thousand for week-over-week tracking and ten thousand to detect a 5% regression. Below a hundred, do not publish the number at all.
Can I use the user-agent string instead of these APIs?
Not usefully. User-agent reduction has removed most of the device detail that once made this possible, and what remains identifies the browser rather than the hardware. A UA-derived tier also changes retroactively every time you update the parser, which quietly rewrites your historical comparisons.
How often should I re-tune the tier boundaries?
Once a quarter, using the separation check above. Device capability moves steadily, so a boundary that split your traffic sensibly two years ago now puts most of the population in one bucket. Re-tuning changes the meaning of the series, so record the date you changed it and treat it as a break in the chart rather than a continuation.
Should Save-Data sessions be excluded from the headline p75?
No — they are real users and they count toward the assessment. Report them as their own cohort as well, because they are a small, self-selected group whose numbers move first when something gets heavier, which makes them an unusually good early-warning signal.
Why do country breakdowns disagree with network breakdowns?
Because a country mixes infrastructure distance with device mix. A country that looks slow may have excellent networks and predominantly low-tier devices, or the reverse. Cross-cut the two before drawing an infrastructure conclusion.
Related
- Classifying Device Tiers in RUM Beacons — the classifier, its fallbacks and how to validate it.
- Segmenting RUM Data by Effective Connection Type — what the Network Information API reports and how to use it.
- RUM Data Sampling Strategies — keeping small cohorts statistically alive under sampling.
- Synthetic vs Field Data Trade-offs — why one lab profile cannot represent this distribution.
- User Impact Mapping — turning a cohort gap into a business case.