Classifying Device Tiers in RUM Beacons
A device tier is a cheap, stable label that separates the population whose CPU is the constraint from the population whose network is. It is the highest-value cohort key in a RUM pipeline and the one most often implemented badly — either as a user-agent parse that rots, or as twelve buckets nobody can compute a percentile over. This guide sits under Device & Network Segmentation and covers building the classifier, handling browsers that expose nothing, tuning the boundaries against your own metrics, and proving the tiers mean something.
Prerequisites
- A beacon pipeline that accepts an extra low-cardinality string field, per Self-Hosted Beacon Collection.
- At least two weeks of field data with a load metric, so you can tune boundaries against something real.
- Agreement that the tier is a correlate of performance, not a claim about hardware. This matters when someone asks whether it is “accurate”.
How to build it
Step 1 — Collect the raw signals once, at page load
// signals.js — read once. These do not change mid-session, and re-reading
// them costs nothing but invites inconsistency.
export function deviceSignals() {
const c = navigator.connection || {};
return {
cores: navigator.hardwareConcurrency || null, // capped by some browsers
memory: navigator.deviceMemory || null, // quantised; absent on Safari
dpr: Math.min(window.devicePixelRatio || 1, 4),
screenMin: Math.min(screen.width, screen.height),
touch: navigator.maxTouchPoints > 0,
effectiveType: c.effectiveType || null,
saveData: Boolean(c.saveData),
};
}
hardwareConcurrency and deviceMemory are the two that carry most of the signal. Both are deliberately coarse — memory is rounded to 0.25, 0.5, 1, 2, 4 or 8 — which is fine, because the classifier only needs to place a session in one of three buckets.
Step 2 — Classify into three tiers, not twelve
// tier.js — three tiers plus an explicit unknown. Resist adding more.
import { deviceSignals } from './signals.js';
export function deviceTier(s = deviceSignals()) {
// Desktop-class: many cores and plenty of memory, no touch primary input.
if (s.cores && s.memory) {
if (s.cores >= 8 && s.memory >= 8) return 'high';
if (s.cores >= 4 && s.memory >= 4) return 'mid';
return 'low';
}
// Safari and older browsers expose neither. Fall back to a screen and
// input heuristic so those sessions land in a named bucket.
if (!s.touch && s.screenMin >= 800) return 'unknown-desktop';
if (s.screenMin >= 700) return 'unknown-tablet';
return 'unknown-mobile';
}
Three tiers is a deliberate limit. Each additional bucket divides your sample, and cohort percentiles need volume — the floors are in the parent guide. Three tiers crossed with a route template is already a lot of cells.
Step 3 — Name the unknowns instead of hiding them
An unknown bucket that is 30% of traffic makes every comparison a statement about the other 70%. Splitting it by screen and input type at least tells you what is in there, and on Safari-heavy traffic the unknown-mobile bucket usually behaves like mid or low — which you can verify empirically.
Step 4 — Attach it, and version it
// cohort.js — attached to every beacon from this page load.
import { deviceTier } from './tier.js';
const CLASSIFIER_VERSION = 3; // bump when boundaries change
const cohort = Object.freeze({
deviceTier: deviceTier(),
tierVersion: CLASSIFIER_VERSION,
});
export const withCohort = (metric) => ({ ...metric, ...cohort });
The version field is not decoration. When you retune the boundaries — and you will, roughly annually — every historical comparison across the change is invalid unless you can filter by version. Without it, a boundary change looks exactly like a population shift.
Step 5 — Tune the boundaries against your own metrics
Default boundaries of 4 and 8 are a starting point, not a standard. Tune them by finding the split that maximises separation between adjacent tiers on a load metric.
-- Candidate boundary sweep: which core count best splits the population?
SELECT
cores,
count() AS sessions,
quantileTDigest(0.75)(value) AS p75_lcp
FROM rum_events
WHERE metric = 'LCP' AND ts >= now() - INTERVAL 28 DAY AND cores IS NOT NULL
GROUP BY cores
ORDER BY cores;
Read the result as a curve. The boundary belongs where the p75 changes sharply between adjacent core counts, not at a round number. On most current traffic that is between 4 and 6 cores rather than at 8, because device capability has moved since the defaults were written.
Step 6 — Validate that the tiers carry information
Two checks, run monthly. Separation: adjacent tiers should differ by more than the noise in either. Stability: the share of traffic in each tier should move slowly.
-- Classifier health in one query: share, value and ratio to the fastest tier.
SELECT
device_tier,
count() AS sessions,
round(count() / sum(count()) OVER (), 3) AS share,
quantileTDigest(0.75)(value) AS p75,
round(quantileTDigest(0.75)(value)
/ min(quantileTDigest(0.75)(value)) OVER (), 2) AS ratio_to_best
FROM rum_events
WHERE metric = 'LCP' AND ts >= now() - INTERVAL 30 DAY
GROUP BY device_tier
ORDER BY p75;
If two adjacent tiers sit within 10% of each other, merge them: the boundary between them is not carrying information and the split is costing you sample size for nothing.
What the classifier changes downstream
Once the tier is on every beacon, three things become possible that were not before, and they are the reason the work is worth doing.
Alerting on the cohort that fails first. A regression that only affects low-tier devices moves the origin p75 barely at all, and moves the low-tier p75 immediately. Alert rules keyed on the cohort catch it hours earlier — the rule design is in Alerting & Regression Detection.
Budgets that reflect the constraint. A budget set against desktop numbers is met permanently and teaches nobody anything. A budget set against the mid tier is the one that produces work, and it is derivable from this data directly.
Honest lab calibration. The CPU throttling multiplier in your lab configuration should approximate a real cohort. With tiers in place you can check that empirically rather than assuming that 4× is still the right number, which is the calibration step in Building a Lab-to-Field Correlation Report.
Verifying it works
- Tier shares are plausible and stable — typically 20–40% high, 30–45% mid, 15–30% low on consumer traffic, with the exact mix depending on your market.
- Adjacent tiers differ by at least 25% on a load metric. Less than that and the boundaries need retuning.
- The unknown buckets sit next to the tiers they resemble, as in the chart above. If they sit somewhere else, the fallback heuristic is wrong for your traffic.
- The classifier version is on every row, and a boundary change produces a visible version boundary in the data.
- Cardinality stays at six values. A seventh value appearing means a client bug or an unhandled signal.
Edge cases and gotchas
hardwareConcurrencyis capped. Some browsers report a maximum regardless of the real core count, which compresses the top of the range. Never treat it as a hardware fact.deviceMemoryis absent on Safari and quantised everywhere. On iOS-heavy traffic the fallback path handles the majority of sessions, so it deserves the same scrutiny as the main path.- Privacy modes and extensions can spoof both values. A small fraction of sessions will report implausible combinations; they land in a tier and do no harm at this granularity.
- Desktop-class tablets exist. A tablet with eight cores classifies as
highand behaves like it. That is correct — the tier is about capability, not form factor. - Do not derive a device model. It is unreliable, it invites a privacy conversation you do not need, and it adds nothing over three buckets.
- Retuning is a break in the series. Treat a version change like a schema migration: announce it, filter by it, and do not compare across it.
FAQ
Why three tiers and not five?
Because every extra bucket divides the sample, and cohort percentiles need volume. Three tiers crossed with a route template and a network type is already several dozen cells, most of which will be near the sample floor on a mid-sized site.
Is this accurate?
It is not a measurement of hardware and does not claim to be. It is a correlate: sessions in the low tier consistently experience worse load metrics than sessions in high. Validate it with the separation check rather than against a device database.
What about the unknown bucket on Safari?
Split it by screen size and input type so it lands in named buckets, then verify empirically where those buckets sit relative to the real tiers. On most traffic unknown-mobile behaves like mid.
How often should boundaries be retuned?
Annually, or whenever the separation check shows two tiers converging. Bump the classifier version when you do, and expect the series to step.
Related
- Device & Network Segmentation — why the cohort exists and what the sample floors are.
- Segmenting RUM Data by Effective Connection Type — the network half of the same cohort key.
- RUM Data Sampling Strategies — keeping the smallest tier above the sample floor under sampling.