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.

Where the unknown buckets actually sitThe fallback buckets land next to the tiers they resemble, which is the evidence that the screen heuristic is doing useful work rather than inventing a category.Where the unknown buckets actually sitp75 LCP by tier, showing how the Safari fallbacks comparehigh1,580 msunknown-desktop1,720 msmid2,740 msunknown-mobile2,910 mslow4,380 ms0 ms1,250 ms2,500 ms3,750 ms5,000 ms
The fallback buckets land next to the tiers they resemble, which is the evidence that the screen heuristic is doing useful work rather than inventing a category.

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.

Traffic share by tier, and how stable it isShares moving by a point or two a quarter is normal device turnover. A ten-point swing in a month is a browser behaviour change, not an audience change.Traffic share by tier, and how stable it isShare of sessions per tier across six monthsFebruary31%34%17%18%April33%34%15%18%June34%35%14%17%highmidlowunknown
Shares moving by a point or two a quarter is normal device turnover. A ten-point swing in a month is a browser behaviour change, not an audience change.

Verifying it works

  1. 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.
  2. Adjacent tiers differ by at least 25% on a load metric. Less than that and the boundaries need retuning.
  3. 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.
  4. The classifier version is on every row, and a boundary change produces a visible version boundary in the data.
  5. Cardinality stays at six values. A seventh value appearing means a client bug or an unhandled signal.
Where the tier boundary belongsThe curve flattens above 8 cores and falls sharply between 4 and 6. That bend, not a round number, is where the boundary carries the most information.Where the tier boundary belongsp75 LCP by reported logical core count, 28 days0 ms2,500 ms5,000 ms7,500 ms10,000 ms0 cores5 cores10 cores15 cores20 coresnavigator.hardwareConcurrencyp75 LCPsteepest drop between 4 and 6
The curve flattens above 8 cores and falls sharply between 4 and 6. That bend, not a round number, is where the boundary carries the most information.

Edge cases and gotchas

  • hardwareConcurrency is 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.
  • deviceMemory is 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 high and 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.