cf.completefrontend
My progress
FUNDAMENTALS · MODULE 15

Real-user monitoring (RUM)

Measuring speed on your real users' devices and networks: collecting Web Vitals and Performance API timings, sending them safely, and reading them as percentiles by device, network, country, and page to find what an average hides.

Why lab numbers are not enough

A travel site ships a new search results page. Before release, the team runs Lighthouse on a fast laptop: LCP (Largest Contentful Paint, when the biggest image or text block shows up) is 1.4 seconds. Everyone is happy.

Two weeks later, bookings from India and Brazil drop by 6%. Nothing in the lab changed. The page still scores well on the laptop. The team has no idea where to look, because the only numbers they have come from a machine none of their users own.

The missing piece is real-user monitoring, or RUM: a small script in the page that measures how fast the page was for each real visit, on that visitor's phone, network, and location, and sends those numbers to your servers. With RUM, the same team would have seen it in an hour: LCP on low-end Android phones in those countries went from 3.1 s to 5.8 s, only on the results page, right after the release.

Lab data and field data

There are two ways to measure speed. Lab data (also called synthetic monitoring) runs a page in a controlled setup: one device, one network profile, one location, no logged-in state. Field data comes from real visits: thousands of different devices, networks, caches, and user actions. One vital, INP (Interaction to Next Paint, how fast the page responds to taps, clicks, and key presses), only exists in the field.

Lab finds problems you can reproduce; the field tells you which problems users actually have.
QuestionLab (Lighthouse, WebPageTest, CI checks)Field (RUM, CrUX)
Who loads the page?A script on one machineEvery real user (or a sample)
Is the result repeatable?Yes, mostly: good for catching regressions before releaseNo: it moves with traffic mix, time of day, and releases
Does it see INP?No real interactions, so only proxies like TBT (Total Blocking Time)Yes: real taps, clicks, and key presses
Does it see logged-in pages, A/B variants, warm caches?Only what you scriptAll of them, as users really see them
Can it explain why?Yes: full trace, filmstrip, waterfallPartly: only what you chose to collect
When do you get it?Before releaseAfter release, once traffic arrives

You need both. The lab is a unit test for speed: fast, repeatable, run in CI. The field is the truth: it is the only place INP exists, and the only place your real device and network mix shows up. When the two disagree, the field wins, and the lab is where you go to reproduce and fix what the field found.

A bar chart of LCP for the same search results page, drawn to scale, with dashed lines at the good limit of 2.5 seconds and the poor limit of 4 seconds. Lab run on an office laptop: 1.4 seconds. Lab run with a throttled phone profile: 2.3 seconds. Field p75 for all visits: 2.7 seconds. Field p75 on mobile: 3.4 seconds. Field p75 on low-end phones in India: 5.8 seconds, poor.2.5 s4 slab, office laptop1.4 slab, throttled phone2.3 sfield p75, all visits2.7 sfield p75, mobile3.4 sfield p75, low-end, IN5.8 ssame page, same day
Both lab runs pass. The field says the page fails for real users, and badly for one group. Lab numbers are not wrong; they describe a machine your users do not own.

The questions RUM answers

  • Are we passing Core Web Vitals for our users right now, page by page?
  • Did yesterday's release make anything slower, and for whom?
  • Which segment is suffering: low-end phones, a country, a browser, logged-in users, one route?
  • Which element is the LCP element, and which script is blocking the slow interactions?
  • Does speed affect the business: do slower sessions convert less?

This lesson builds a RUM pipeline from zero: what to measure with which browser API, when to send it so you do not lose data, how to tag each sample so you can split it later, and how to read the results with percentiles and histograms instead of averages. The earlier lesson on Core Web Vitals explains the three metrics and their thresholds; here we focus on collecting and reading them at scale. The frontend logging lesson covers the shared transport (beacons, batching, sampling) in more depth.

The mental model: a pipeline from tab to chart

Think of RUM as a weather network. Each visit is a small weather station: it records a few readings (LCP, INP, CLS for Cumulative Layout Shift, and TTFB for time to first byte) and a few facts about where it stands (device, network, country, page). The readings travel to a central office, which never looks at one station alone. It builds maps: how hot it was, region by region, hour by hour.

That picture gives the two rules of RUM. First, one sample means nothing; only distributions over many samples mean something. Second, a reading without its context is almost useless, because the whole point is to compare groups later.

Five stages

  1. Collect in the page: a small script observes Web Vitals and Performance API entries as the browser records them.
  2. Send at the right moment: samples are queued and flushed in one request when the page is hidden, with navigator.sendBeacon() or fetch with keepalive.
  3. Enrich at the collector: an endpoint you own adds what the browser should not send or cannot know: country from the IP address (then drops the IP), browser and OS parsed from the user agent, a received-at time.
  4. Store as raw rows in a columnar store (ClickHouse, BigQuery, or a vendor's backend): one row per metric per page view, with every tag as a column.
  5. Aggregate and read: queries compute percentiles and histograms per segment per hour or day, which feed dashboards and alerts.
A RUM pipeline in five stages. In the browser: 1, collect, with web-vitals and performance observers; 2, send, with a beacon when the page is hidden. In your backend: 3, enrich, which adds the country and drops the IP; 4, store, raw rows in a columnar store. In analytics: 5, aggregate into p75 per segment, which feeds dashboards and alerts.BrowserYour backendAnalytics1 collectweb-vitals, observers2 sendbeacon on hide3 enrich+ country, - IP4 storeraw rows5 aggregatep75 per segmentdashboardsand alerts
The page only measures, queues, and sends once. Everything that needs the IP, heavy parsing, or statistics happens on your side, where it costs the user nothing.

Each stage has one main way to fail. Collect too much and you slow the page you are measuring; send too late and the tab is gone. Forget to enrich and you cannot split by country. Store pre-averaged numbers and you can never compute a percentile again; read the average and you miss the users who suffer.

What one sample looks like

A good RUM payload is small and flat. Here is one page view on a phone, after the collector has enriched it. Every field after value is a tag, a dimension you can group by later.

json
{
  "metric": "LCP",
  "value": 3240,
  "rating": "needs-improvement",
  "id": "v6-1792400000000-4870213365512",
  "navigationType": "navigate",
  "route": "/hotels/[city]",
  "release": "web@2026.10.18-2",
  "deviceClass": "low",
  "effectiveType": "4g",
  "rtt": 250,
  "country": "IN",
  "browser": "Chrome 151",
  "lcpElement": "img.hero",
  "lcpResourceLoadDelay": 1100
}
One LCP sample. The id ties the metrics of one page view together; the route is a pattern, never the full URL.

Notice what is missing: no user id, no full URL with query strings, no IP address. A RUM dataset answers questions about groups, so it rarely needs to know who anyone is. The privacy chapter returns to this.

Check your understanding · Exercise 1

Your dashboard shows LCP by country, but every row says unknown. Which stages could be at fault? Select all that apply.

Measuring Web Vitals in the field

The browser exposes the raw entries behind each vital (largest-contentful-paint, event, layout-shift). Turning them into the official numbers is harder than it looks, so almost every team uses Google's open source web-vitals library. It is about 3 KB compressed and matches how Chrome and CrUX (the Chrome User Experience Report) compute the same metrics.

Why not compute them yourself?

Each metric has rules that are easy to get wrong. A hand-written version usually matches the library on a simple page and drifts on real traffic. Some of the rules the library handles for you:

  • LCP keeps changing while the page loads, as bigger elements paint. It stops at the first tap, click, key press, or scroll, and it must ignore pages that loaded in a background tab.
  • INP is not the slowest interaction: on visits with 50 or more interactions it skips one outlier for every 50, so one freak 2-second click on a long session does not decide it.
  • CLS groups shifts into session windows (gaps under 1 second, windows at most 5 seconds long) and reports the worst window, and it ignores shifts within 500 ms of user input.
  • Back/forward cache restores are new page views for the user. The library starts fresh metrics for them and marks them with navigationType: "back-forward-cache".
  • Prerendered pages count time from when the user activated the page, not from when the prerender started.

The callbacks and when they fire

You register one callback per metric: onLCP, onINP, onCLS, and the diagnostic ones onFCP (First Contentful Paint) and onTTFB. Each callback receives a metric object with name, value, rating (good, needs-improvement, or poor), delta, id, navigationType, and the raw entries.

The key fact: most callbacks fire late. INP and CLS can only be final when the visit ends, so the library reports them when the page becomes hidden. LCP reports at the first input or when the page is hidden, which on a long reading session is minutes after load. If your code only sends data on load, you lose INP and CLS completely.

A timeline of one visit, from navigation start through the load event at 1.8 seconds, the first tap at 6 seconds, and the tab being hidden after 4 minutes. TTFB is reported right away and FCP at first paint. LCP is reported at the first tap. INP and CLS are reported only when the page is hidden, at the very end of the visit.TTFBright awayFCPat first paintLCPat first tapINPCLSwhen hidden0load1.8 stap6 shidden4 mintime
The numbers you care most about arrive last. Code that sends on load misses INP and CLS completely, and a flush on visibilitychange catches them.
ts
import { onCLS, onINP, onLCP, onTTFB } from "web-vitals/attribution";
import type { MetricWithAttribution } from "web-vitals/attribution";

function record(m: MetricWithAttribution) {
  rumQueue.push({
    metric: m.name,
    value: Math.round(m.name === "CLS" ? m.value * 1000 : m.value),
    rating: m.rating,
    id: m.id,
    navigationType: m.navigationType,
    ...pickAttribution(m),
    ...pageTags(),
  });
}

onTTFB(record);
onLCP(record);
onINP(record);
onCLS(record);
One callback for every metric. CLS is stored times 1000 so every value is an integer; pageTags adds the route, release, and device tags.

value, delta, and id

By default each metric reports once per page view. With the option reportAllChanges: true, LCP, INP, and CLS report every time they change, so the same id arrives several times, and you must not count it twice.

Two safe patterns: keep only the last value per id at query time, or send delta (the change since the last report) to a tool that sums values. Never average all reports of the same id.

The attribution build: the why behind the number

A p75 LCP of 4 seconds tells you there is a problem. It does not tell you what to fix. The attribution build (import from web-vitals/attribution) adds an attribution object to every metric, for about 1.5 KB more. It is the single most useful upgrade to a basic RUM setup.

Field names as of web-vitals version 5 and later. Version 4 called the LCP selector attribution.element; version 5 renamed it attribution.target.
MetricAttribution fields worth storingThe question it answers
LCPtarget (the LCP element's selector) and url, plus timeToFirstByte, resourceLoadDelay, resourceLoadDuration, elementRenderDelayWas it the server, a late-discovered image, a heavy image, or render-blocking work?
INPinteractionTarget, interactionType, inputDelay, processingDuration, presentationDelay, loadStateWhich control is slow, and is the time spent waiting, in handlers, or painting?
CLSlargestShiftTarget, largestShiftValue, largestShiftTime, loadStateWhich element moved, and did it happen during load or later?

The four LCP parts always add up to the LCP value, and the three INP phases add up to the INP value. That is what makes them so useful on a dashboard: you can stack them as bars and see at a glance which part grew after a release.

Check your understanding · Exercise 2

A team sends RUM data in a load event handler. Which statements are true? Select all that apply.

The Performance APIs underneath

The web-vitals library is built on a family of browser APIs. You can use them directly to measure things the vitals do not cover: where a slow TTFB comes from, which third-party script is heavy, or when your own key feature became usable.

PerformanceObserver: subscribe, do not poll

The browser records performance entries as things happen: a navigation, a resource download, a paint, a slow frame. A PerformanceObserver hands them to your callback in batches. Pass buffered: true so you also get entries recorded before your script ran, which matters because RUM code should load late.

ts
const supported = PerformanceObserver.supportedEntryTypes;

if (supported.includes("long-animation-frame")) {
  new PerformanceObserver((list) => {
    for (const frame of list.getEntries() as PerformanceLongAnimationFrameTiming[]) {
      const worst = frame.scripts.toSorted((a, b) => b.duration - a.duration)[0];
      rumQueue.push({
        metric: "LoAF",
        value: Math.round(frame.duration),
        blocking: Math.round(frame.blockingDuration),
        script: worst?.sourceURL ?? "unknown",
      });
    }
  }).observe({ type: "long-animation-frame", buffered: true });
}
Feature-detect with supportedEntryTypes, then observe. Each long frame lists the scripts that ran in it.

performance.getEntriesByType("navigation")[0] returns one PerformanceNavigationTiming entry for the document. Its timestamps, all in milliseconds from the start of the navigation, split the load into phases. Subtract neighbours to get each phase's duration.

A waterfall of one page load on a phone, from Navigation Timing, on an axis from 0 to 2 seconds. Cache and service worker check: 0 to 10 milliseconds. DNS: 10 to 50. Connect and TLS: 50 to 170. Server wait: 170 to 550, the largest phase. Download HTML: 550 to 610. Parse and run scripts until DOMContentLoaded at 900. The load event fires at 1,800. TTFB, responseStart, is 550 milliseconds.cache, service worker10 msDNS40 msconnect + TLS120 msserver wait380 msdownload HTML60 msparse, run scripts290 msDOMContentLoadedloadTTFB 550 ms02,000 ms
Subtract neighbouring timestamps to get each phase. Here the server wait is 380 of the 550 ms TTFB, so Server-Timing is the next thing to read, not the network.
TTFB is responseStart. The entry also has transferSize, so a value of 0 tells you the document came from the HTTP cache.
PhaseComputed asA big value usually means
RedirectsredirectEnd - redirectStartA chain like http to https to www; fix the links
Service worker and cachedomainLookupStart - fetchStartA slow service worker start or cache lookup
DNSdomainLookupEnd - domainLookupStartFirst visit or a cold DNS cache; consider fewer origins
Connect and TLSconnectEnd - connectStartNo connection reuse, or a far-away server; a CDN helps
Server waitresponseStart - requestStartSlow server work or a cache miss at the CDN
DownloadresponseEnd - responseStartA large HTML document on a slow network

Server-Timing: let the backend explain its part

When server wait is the problem, the Server-Timing response header lets the server say why. Each entry has a name, an optional duration, and an optional description. The browser exposes them as serverTiming on the navigation and resource entries, so your RUM script can attach them to the sample.

http
HTTP/2 200
content-type: text/html
server-timing: cdn-cache;desc="MISS", db;dur=212, render;dur=148
The CDN reports a miss, the database took 212 ms, and server rendering took 148 ms.

Now a dashboard can split TTFB by cdn-cache hit and miss, which often explains a bad p75 TTFB in one chart. Be careful what you put in it: every visitor can read these headers, so never include internal host names, SQL, or anything secret.

Resource Timing and Timing-Allow-Origin

Every image, script, stylesheet, font, and fetch call gets a PerformanceResourceTiming entry with the same phase timestamps as the navigation. Use it to find the slow API call behind a slow page, or to check whether the LCP image came from the cache.

There is a privacy rule. For a cross-origin resource, such as an image on your CDN domain or a third-party script, the browser hides the detail: the phase timestamps and the sizes read as 0, and you only get the start time and total duration.

The other origin must opt in by sending Timing-Allow-Origin with your origin (or *). The same header also decides whether your script can read that response's Server-Timing values. Add it to your own CDN and API responses; you cannot add it to someone else's.

The resource buffer also has a size limit: 250 entries by default. A busy single-page app fills it fast, and later entries are dropped. Use a PerformanceObserver for resources instead of reading the buffer once, and only keep the few resources you care about.

Element Timing and User Timing: your own moments

  • User Timing works in every modern browser. Call performance.mark("results:visible") at the moment that matters for your product, and performance.measure() to get a duration between two marks. Observe the mark and measure entry types to send them.
  • Element Timing tells you when a specific image or text block painted. Add an elementtiming="hero" attribute to the element and observe the element entry type. It is supported in Chromium-based browsers only, so treat it as extra detail, not a metric for everyone.
  • Event Timing (the event entry type) gives the duration of slow events, such as clicks and key presses, over a threshold (104 ms by default). INP is built on it; you rarely need it directly.
  • Long Animation Frames (LoAF, the long-animation-frame type, Chrome 123 and later) report frames that took over 50 ms, with blockingDuration and a scripts list that names the source file and function. It replaced the older Long Tasks API for this job, because long tasks never said which script was to blame.

Check your understanding · Exercise 3

Your Resource Timing data shows transferSize: 0 and responseStart: 0 for every image on img.example-cdn.com, but correct values for scripts on your own origin. What is going on? Select all that apply.

Sending at the right time, and single-page apps

Since the most important values arrive when the user leaves, the send logic decides how much data you keep. The frontend logging lesson covers the transport in depth; here is what matters for RUM.

Flush when the page is hidden

  1. Collect samples into an in-memory queue as callbacks fire. Do no network work inside the callbacks.
  2. Listen for visibilitychange. When document.visibilityState becomes hidden, serialize the whole queue into one body and send it.
  3. Send with navigator.sendBeacon(url, body) or fetch(url, { method: "POST", body, keepalive: true }). Both let the request finish after the tab is gone. All keepalive bodies in flight share a 64 KiB budget, and a RUM batch is usually 1 to 3 KB.
  4. Also listen for pagehide as a backup for older Safari versions, and make the flush idempotent so a second call sends nothing.
  5. Never use the unload event. It is unreliable on mobile, and it stops the page from entering the back/forward cache, which makes the very next navigation slower.
A sequence diagram with four columns: web-vitals, the page's queue, the browser, and the collector. web-vitals pushes LCP 2140 into the queue at the first tap. Later the user switches app and the browser fires visibilitychange hidden. web-vitals pushes the final INP of 280 and CLS of 0.04 into the queue. The page calls sendBeacon with the batch, and the browser answers true, queued. After the page is gone, the browser posts 1.6 kilobytes to the collector at /rum, which answers 204.web-vitalsqueuebrowsercollectorLCP 2140hiddenINP 280, CLS 0.04sendBeacontruePOST 1.6 KB204
Register your hide listener after the onINP and onCLS calls, as the library's own batching example does, so their final values are already queued when you flush. The browser finishes the request even if the tab is frozen or closed.

Why hidden and not "before close"? On phones, users rarely close tabs. They switch apps, lock the screen, or let the OS kill the browser in the background. visibilitychange is the last event you can count on in all of those cases.

A page can become hidden and visible many times, so after a flush, keep collecting: the library may report a larger INP or CLS later, which you send on the next hide with the same id.

Do not slow down what you measure

  • Load the RUM script after the main content, with async or a dynamic import(). Thanks to buffered: true, it still sees early entries.
  • Keep it small: web-vitals with attribution is about 4.5 KB compressed, so with your glue code aim for 5 to 6 KB.
  • Never do work inside an interaction. The INP callback fires during a hide, but LoAF and resource observers fire often: just push to the queue.
  • Send to your own domain (for example /rum), not a third-party host. It avoids an extra DNS and TLS connection, and ad blockers drop fewer requests.

Single-page apps and soft navigations

In a single-page app (SPA), clicking a link changes the URL with the History API, but the document stays the same. The browser calls this a soft navigation. The classic metrics only know about the real ("hard") navigation, which causes three problems for RUM.

  • No LCP for route changes. Only the landing page gets an LCP. A slow transition from search to hotel details is invisible.
  • INP and CLS pile onto the landing URL. The worst interaction of the whole visit is reported once, at the end, tagged with whatever route you stored at the start.
  • Fewer page views than you think. A user who browses 12 routes in one visit counts as one sample for the landing route and zero for the others.

The approach that works in every browser: tag every sample with the route where it happened, not where the visit started. For INP, the attribution includes the interaction's time, so you can look up which route was active then. For route changes, measure your own "route ready" time with User Timing: mark on click, mark when the new view has rendered its main content, and send the measure.

Chromium can now measure this for you. Since Chrome 151, the browser detects a soft navigation (a user interaction, then a URL change, then a paint) and records a soft-navigation performance entry, plus interaction-contentful-paint entries that give the new view an LCP. Version 6 of web-vitals uses them when you pass reportSoftNavs: true: each route change gets its own LCP, INP, and CLS, with navigationType set to soft-navigation and TTFB reported as 0.

Two cautions. Safari, Firefox, and older Chrome versions report nothing for soft navigations, so keep the User Timing route measure as the number that works everywhere, and chart soft-navigation metrics as their own segment. Also, the option changes your landing-page numbers: its INP and CLS are now final at the first route change, not at the end of the visit.

Think first

A SPA's landing route / has a p75 INP of 450 ms, but the home page has almost no JavaScript handlers. What is the likely cause, and how do you confirm it?

Segmenting: finding who is suffering

One global number hides the users who need help. Desktop users on fiber can make the site-wide p75 LCP look fine while phones in one country are far over the line. Segmenting means tagging every sample with a few attributes and comparing the distributions of each group.

The dimensions that matter

Nine useful tags. Each one should have a small, fixed set of values.
DimensionHow you get itWatch out for
Page typeThe route pattern from your router, like /hotels/[city]Full URLs explode into millions of values and leak data
Form factorMobile, tablet, or desktop from the user agent at the collectorTablets in desktop mode report a desktop user agent
Device classnavigator.deviceMemory and navigator.hardwareConcurrency, bucketed into low, mid, highdeviceMemory is Chromium-only and rounded (for example 2, 4, 8); Safari users land in unknown
Networknavigator.connection.effectiveType, rtt, downlink, saveDataThe Network Information API is Chromium-only; values are rounded estimates and effectiveType tops out at 4g
CountryAdded at the collector from the IP, or from a CDN header such as CloudFront-Viewer-CountryDrop the IP after the lookup; VPNs and corporate proxies blur it
Browser and versionParsed from the user agent at the collectorNot every browser supports every metric, so compare like with like
ReleaseA build id baked into the bundleWithout it, you cannot tie a regression to a deploy
Navigation typenavigationType from the libraryBack/forward cache restores and prerenders are near-instant and pull the numbers down
Experiment variant, logged inFrom your app stateMake sure the RUM sample and the experiment share the same session id

Browser support shapes what you can conclude. Chromium-based browsers report all three vitals. LCP and INP became Baseline in December 2025 (Firefox 122 and 144, Safari 26.2), but CLS is still Chromium-only, and the newer browsers have far less history in your data. A safe default: build the main dashboards on Chromium data, and show other browsers as their own segments.

A tree of p75 LCP in seconds. All visits: 2.7. Split into mobile 3.4 and desktop 1.9. Mobile splits by device class into high-end 2.4, mid 3.0, and low-end 4.9. Low-end splits by country into India 5.8 and Brazil 5.1. Values over 4 seconds are poor, over 2.5 need improvement.2.7all visits3.4mobile1.9desktop2.4high-end3.0mid4.9low-end5.8India5.1Brazilby device classby form factorby countryp75 LCP, s
Each level splits by one tag. The site-wide number needs improvement, but the real problem is low-end phones in two countries, where the page is poor. Drill one tag at a time and keep an eye on sample counts as groups get small.

Keep every tag low-cardinality

Cardinality is the number of distinct values a tag can take. Device class has 4 (low, mid, high, unknown). A raw URL can have millions. Each extra tag multiplies the number of groups, and each group needs enough samples to give a stable percentile.

A rough rule: you need a few hundred samples in a group before its p75 stops jumping around from day to day, and more for p95. With 9 tags of 5 values each, most combinations will be nearly empty. So slice one or two tags at a time, and keep the raw rows so you can pick the slice when you need it.

Mix shift: when every group improves and the total gets worse

Segments also protect you from a classic trap. Suppose a marketing campaign brings many new visitors from a region with slower phones. Every segment's p75 stays the same or improves, but the site-wide p75 gets worse, because the mix of traffic moved toward the slow segment. This is a form of Simpson's paradox.

Week 2 traffic from low-end phones:
Week 2 release:
p75 LCP in week 1 and week 2. Fast phones and desktops: 1.8 s then 1.7 s. Low-end phones: 4.3 s then 3.9 s. All visits: 2.1 s then 2.9 s. Low-end phones went from 15% to 40% of traffic.fast devices, 85% then 60%week 11.8 sweek 21.7 slow-end phones, 15% then 40%week 14.3 sweek 23.9 sall visitsweek 12.1 sweek 22.9 s2.5 s4 s
Each group is drawn from the same two distributions every week; only the traffic mix and the release change. When a campaign brings more slow devices, the site-wide p75 can get worse even though no group got slower.

Site-wide p75 rose 37% (2.1 s to 2.9 s). No group got slower: this is mix shift, not a regression. A team reading only the total would hunt for a bug that is not there.

Without segments, the team would hunt for a code regression that does not exist. With segments, the answer is clear in one chart: the code did not change; the audience did. Always check the traffic share of each segment next to its percentile.

Check your understanding · Exercise 4

You want to split INP by network quality for all users. Which statements are correct? Select all that apply.

Percentiles, histograms, and why averages lie

Page load times are not spread evenly around a middle. Most visits bunch up at the fast end, and a long tail stretches to the right: old phones, bad networks, cold caches. Statisticians call this a right-skewed (often roughly log-normal) distribution. It changes which summary number you can trust.

The trouble with the mean

Take ten LCP samples in seconds: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.8, 2.0, 2.4, and 12.0. The mean is 2.6 s, which says the page is just over the line. But nine of the ten visits were 2.4 s or faster, and one visit was terrible. The mean describes nobody.

  • The mean moves with outliers. One stuck 60-second sample from a sleeping laptop can shift an hourly mean more than a real regression.
  • The mean hides how many people suffer. A mean of 2.6 s fits both "everyone is at 2.6 s" and "90% are fast, 10% are awful". Those need very different fixes.
  • The mean cannot be compared to the thresholds. Core Web Vitals thresholds are defined at the 75th percentile, so a mean is the wrong number to compare with 2.5 s.

What a percentile says

The p-th percentile is the value that p% of samples are at or below. In the ten samples above, p50 (the median) is about 1.5 s, p75 is about 2.0 s, and p95 is at the 12-second outlier. Each one answers a different question.

No single statistic is enough. p75 is the goal; p95 is the early warning; p50 tells the story to everyone else.
StatisticThe question it answersUse it for
MeanIf we added every visit's time and split it evenly, what would each get?Capacity and cost, rarely for user experience
p50 (median)What does a typical visit look like?Explaining speed to non-engineers; seeing broad shifts
p75Are at least three in four visits good?Core Web Vitals pass or fail; team goals
p95, p99How bad is it for the unlucky visits?Finding tail problems; alerting on new outliers

Why p75 for the vitals? It is strict enough that most users must have a good visit, and loose enough that a few visitors on broken networks cannot decide the result on their own. Teams that serve many low-end devices often also set a goal at p90 or p95.

A histogram of 1,000 LCP samples in half-second buckets from 0 to over 6 seconds. Most samples sit between 1 and 2.5 seconds, the tallest bucket is 1.5 to 2 seconds with 210 samples, and a long tail runs past 6 seconds. The median is 2.1 seconds, the mean 2.5 seconds, the p75 3.1 seconds, and the p95 5.1 seconds. 62% of samples are good, 25% need improvement, and 13% are poor.p50 2.1 smean 2.5 sp75 3.1 sp95 5.1 s02.5 s4 s6 s+samples per half second of LCP
A right-skewed shape: the mean sits right at the 2.5 s line and looks borderline, while p75 shows the page fails. Bucket shares (62% good, 25% needs improvement, 13% poor) are often the clearest way to show this to non-engineers.

Histograms show the shape

A histogram counts samples in buckets (0 to 0.5 s, 0.5 to 1 s, and so on). It shows what one number cannot: two humps instead of one, for example, which usually means two groups of users mixed together, such as cache hits and misses. When a histogram has two humps, stop and segment before you read any percentile.

The simplest histogram has three buckets: good, needs improvement, and poor, using the vitals thresholds. CrUX reports exactly this, and it reads well on a dashboard: "68% good, 21% needs improvement, 11% poor" is clearer for a product manager than "p75 2.7 s".

Lab: the same data, different conclusions

The lab below holds a synthetic population of 2,000 page loads for one product page. Pick a segment and a statistic, and watch how the verdict changes. Try the site-wide p75, then low-end phones only, then switch between the mean and p95 for the slow-network segment.

Device:
Network:Country:
Statistic:
LCP of 2000 synthetic page loads for all devices, any network, all countries. mean 3.2 s, p50 2.5 s, p75 3.9 s, p95 7.5 s. The p75 is 3.9 s, needs improvement. Below, the p75 by device class with the same network and country filters: desktop 2.3 s, mobile, high-end 3.1 s, mobile, mid 4.4 s, mobile, low-end 6.4 s.LCP histogram, 2000 loadsp75 3.9 s0.0 s2.5 s4.0 s8 s+p75 by device class, same filtersdesktop2.3 s (n=615)mobile, high-end3.1 s (n=593)mobile, mid4.4 s (n=464)mobile, low-end6.4 s (n=328)
Top: the shape of the selected segment, with the chosen statistic marked. Bottom: the same statistic for each device class under the same network and country filters. The mean drifts with the stalled tail, p50 shows only the typical visit, and p75 is what the Core Web Vitals line is drawn against.

all devices, any network, all countries: p75 LCP is 3.9 s, which reads as needs improvement. For the same loads: mean 3.2 s, p50 2.5 s, p75 3.9 s, p95 7.5 s.

Computing percentiles at scale

Percentiles have one awkward property: you cannot average them. The p75 of a whole day is not the mean of 24 hourly p75s, and the p75 of the site is not the mean of each country's p75. To combine groups you need the underlying data.

  • Raw rows: store each sample and compute the percentile at query time. Exact and flexible, but costly beyond a few billion rows.
  • Fixed histograms: store counts per bucket per group per hour. Buckets add up across groups and hours, so you can merge them and read approximate percentiles. Choose buckets that are finer near the thresholds.
  • Sketches: data structures such as t-digest, DDSketch, or HDR Histogram keep a small summary that merges cleanly and gives percentiles within a known error, often 1 to 2%. Most RUM vendors and time-series databases use one of these.

Check your understanding · Exercise 5

Yesterday's mean LCP improved from 2.4 s to 2.1 s, but p75 went from 2.3 s to 2.6 s. What can explain this? Select all that apply.

CrUX, dashboards, and alerting on regressions

CrUX versus your own RUM

You already have some field data for free. The Chrome User Experience Report (CrUX) collects vitals from opted-in Chrome users on desktop and Android and publishes the p75 over a rolling 28-day window, for whole origins and for URLs with enough traffic. PageSpeed Insights and Search Console show it, and it is what Google Search uses.

CrUX is the scoreboard, but it is a poor tool for fixing things. It is 28 days slow to react, it has no iOS data (Chrome on iOS uses WebKit and does not report), it does not know your routes or releases, and it has no attribution. Pages that are not publicly discoverable, such as those behind a login, never get their own URL-level data; they only count toward the origin's numbers. Your own RUM fills every one of those gaps.

Decision Where should your field data come from?

CrUX only

Read PageSpeed Insights, Search Console, or the CrUX API and BigQuery dataset

Pros

  • Free, no code in your page
  • Exactly the data Search uses
  • Compare against competitors' origins

Cons

  • 28-day window, so a regression shows up slowly
  • Chrome only, no iOS
  • No routes, releases, segments of your own, or attribution

A RUM vendor

Add the vendor's script (Datadog, New Relic, Sentry, SpeedCurve, and others)

Configure tags and sampling in their tool

Pros

  • Dashboards, percentiles, and alerts on day one
  • Often joined with errors and traces

Cons

  • Priced per session or event, so cost grows with traffic
  • Another third-party script on the page
  • Less control over what is collected and where it is stored

Build your own on web-vitals

Add web-vitals/attribution and a beacon to your own endpoint

Store rows in ClickHouse or BigQuery; chart with Grafana or similar

Pros

  • Full control of tags, privacy, and retention
  • Cheap per sample at scale
  • Joins with your own product data

Cons

  • You build and run the pipeline, dashboards, and alerts
  • Easy to get the statistics wrong

What we pick: Use CrUX as the external scoreboard everywhere. Small and medium teams should add a RUM vendor for fast, segmented data. Large sites with a data platform often build their own on web-vitals, because the per-session price of a vendor grows with traffic and they want to join speed with business data.

A dashboard people actually use

A RUM dashboard has one job: let someone see in 30 seconds whether things are fine, and if not, where to look. A layout that works:

  1. Top row, the verdict: p75 LCP, INP, and CLS for the last 24 hours, each colored by its threshold, with the change from the same day last week.
  2. Second row, the shape: the good, needs improvement, and poor shares for each metric over 30 days, as stacked bars.
  3. Third row, the breakdown: p75 by page type and by device class, with sample counts and traffic share next to each.
  4. Fourth row, the why: the LCP parts and INP phases as stacked bars over time, and the top LCP elements and interaction targets.
  5. Release markers drawn on every time chart, so a step change lines up with a deploy.
A wireframe of a RUM dashboard for the search page. Top row: LCP p75 2.4 seconds, up 3%, good; INP p75 240 milliseconds, up 18%, needs improvement; CLS p75 0.05, unchanged. Middle: INP p75 over 30 days, flat and then rising after a release marker labelled web 10.18. Bottom left: p75 LCP by device class, low-end 4.1 seconds, mid 2.9, high-end 2.0. Bottom right: INP at p75 split into input delay 60, handlers 130, and paint 50 milliseconds.RUM: /search, last 24 hoursLCP p752.4 s, +3%INP p75240 ms, +18%CLS p750.05, 0%INP p75, 30 daysweb@10.18LCP p75 by devicelow4.1 smid2.9 shigh2 sINP phases at p75, msdelay 60handlers 130paint 50
Verdict first, then the trend with release markers, then where and why. Here the INP rise lines up with one release, and the phase split says the new event handlers are the cause.

Alerting on regressions without crying wolf

RUM data is noisy. Traffic changes by hour and weekday, and small segments jump around. An alert on "p75 LCP over 2.5 s for 5 minutes" will page someone every night. Good RUM alerts are slower and smarter:

  • Compare against a baseline, not a fixed line: the same hour last week, or the last 7 days, per page type and device class. Alert on a relative change, such as p75 up 15%.
  • Require enough samples: skip any group under a minimum count (say 500 samples in the window). Small groups are where false alarms come from.
  • Require it to last: for example, two windows of 30 minutes in a row. A single bad window is usually noise or a traffic blip.
  • Tie it to releases: the strongest signal is comparing the new release's samples to the old release's samples during a gradual rollout, at the same time, on the same traffic mix.
  • Route alerts to an owner: an alert on the checkout page goes to the checkout team, with links to the attribution breakdown.

Think first

Your RUM dashboard shows p75 LCP of 2.1 s for the home page, but Search Console says the same URL fails at 2.9 s. Who is right?

Sampling, cost, privacy, and what breaks

How much data you really need

A site with 20 million page views a day, sending 5 metrics per view, produces 100 million rows a day. At about 200 bytes per row that is 20 GB a day before compression, and every row costs money in a vendor or a warehouse. Most of those rows add almost no information: the p75 of a busy page from 50,000 samples is within about 10 milliseconds of the p75 from 5 million.

So sample. The rule that keeps the statistics honest: decide once per session, at random, and keep all of that session's samples. Hash the session id and keep the session if the hash falls below the rate. Never sample per metric or per event, or you will have LCP for one visit and INP for another and can no longer join them.

ts
const RATES: Record<string, number> = { "/": 0.05, "/checkout": 1, default: 0.2 };

function inSample(sessionId: string, route: string): boolean {
  const rate = RATES[route] ?? RATES.default;
  return (hash32(sessionId) % 10_000) / 10_000 < rate;
}

// Store the rate on each row, so queries can weight counts back up.
const tags = { sampleRate: RATES[route] ?? RATES.default };
Per-session sampling with a higher rate for low-traffic, high-value routes. Percentiles inside one route do not need weights; totals across routes do.
  • Busy pages can run at 1 to 10%; their percentiles stay stable.
  • Rare but important pages (checkout, sign-up) need 100%, or their segments will be too small to read.
  • When rates differ by route, store the rate on each row. Site-wide totals and blended percentiles must weight each sample by one over its rate.
  • Sample after consent, not before, if your consent rules require it (see below).

Privacy: measure groups, not people

Performance data looks harmless, but a RUM row can still identify a person when you add enough tags: an exact timestamp, a full URL with an email in the query string, a precise location, a rare device. Under laws like the GDPR, data that can single someone out is personal data, whatever you meant to use it for.

  • Send route patterns, not URLs. /orders/[id] instead of /orders/58213?email=....
  • Drop the IP at the collector after you derive the country (or region at most). Do not store it next to the samples.
  • No user ids. Use a random session id that lives only for the visit. If you must join RUM with product data, do it in aggregate.
  • Scrub attribution selectors and Server-Timing descriptions, which can contain ids, names, or search terms.
  • Respect consent where your lawyers say performance data needs it. Many teams treat strictly anonymous, aggregated RUM as necessary for operating the service, but that is a legal decision per region, not a technical one.
  • Set a retention period, such as 13 months for raw rows, and keep longer history only as aggregates.

What breaks in production

Most RUM bugs make the data look better than reality. Treat a sudden improvement with the same suspicion as a sudden regression.
SymptomLikely causeFix
INP and CLS sample counts are far below LCPData is sent on load or beforeunload, not on hideFlush on visibilitychange to hidden, with pagehide as a backup
p75 drops sharply after a release that changed nothingThe script now loads later and misses slow visits that leave earlyLoad RUM earlier, and track the share of page views with a sample
p75 improves when a new feature launchesThe feature enabled the back/forward cache, and instant restores pulled the numbers downReport navigationType and chart restores separately
One country's numbers look implausibly fastBots or synthetic monitors with a real browser are countedFilter known bots and your own synthetic checks at the collector
The bill doubledA new tag with high cardinality, or sampling turned off for testingCap tag values at the collector; alert on rows per day
Ad blockers drop a noticeable share of beacons on some audiencesThe endpoint is on a vendor domain or has a blocked path like /analyticsSend to a first-party path on your own domain

Check your understanding · Exercise 6

Which sampling setups keep percentiles trustworthy? Select all that apply.

RUM in real products and in the interview

How real teams run it

  • Hosting platforms such as Vercel Speed Insights and Cloudflare Web Analytics collect Web Vitals from real visits with a small script and show p75 per route. They are built on the same browser APIs, often on the web-vitals library itself.
  • Observability vendors such as Datadog, New Relic, Sentry, and SpeedCurve sell RUM next to error tracking and backend tracing. Their main selling point is the join: a slow page view links to its errors and to the backend trace of its API calls.
  • Large e-commerce and media sites usually run their own pipeline: web-vitals plus a first-party beacon into their data warehouse, so they can put speed next to conversion and revenue by segment.

The pattern behind all of them is the same as in this lesson: collect in the page with standard APIs, send on hide to a nearby endpoint, enrich and store raw or sketched data, and read percentiles by segment and release.

Connecting speed to the business

RUM is also how you justify performance work. Group sessions by their LCP bucket and compare conversion or bounce rate across buckets. If sessions with LCP under 2 s convert at 3.1% and sessions over 4 s convert at 2.2%, that gap is a strong argument for the next quarter's roadmap.

Be careful with the conclusion, though. Slow sessions are also cheaper phones, worse networks, and different countries, which convert differently for reasons unrelated to speed. Compare within a segment, or better, run an experiment that slows down or speeds up a page for a random group. Correlation in RUM is a hint; an A/B test is proof.

When to bring it up

RUM belongs in almost every app-level design question, usually in the last ten minutes, under "how would you know it works in production?". It is a strong move to bring it up unprompted, because it shows you think about the system after launch. A news feed, a search page, a checkout, a dashboard: all of them have a speed goal that only field data can confirm.

  • Name the goal: "p75 LCP under 2.5 s and INP under 200 ms on mobile, measured on real users."
  • Draw the pipeline: page, beacon on hide, collector that adds country and drops the IP, store, dashboard and alerts.
  • Name the tags: route, device class, network, country, release, and experiment variant.
  • Say how you read it: percentiles, not means; segments with sample counts; release comparison during rollout.
  • Add a product metric: a custom User Timing mark for the moment that matters, like "first result visible".
A flowchart for a worse p75. Start: p75 got worse for a segment. First question: did the traffic mix change? If yes, it is a mix shift with no code to fix. If no: is it tied to a release, comparing old and new release samples? If yes, roll back or fix forward. If no: which part grew, from the LCP parts or INP phases? TTFB points at the server or CDN, load delay or duration at images and fonts, and INP phases at scripts found with Long Animation Frames.p75 got worsefor one segmentmix changed?traffic share by segmentmix shiftno code to fixone release?old vs new samplesroll backor fix forwardwhich part grew?LCP parts, INP phasesserver, CDNimages, fontsscripts, LoAFyesyesnonoTTFBINP phases
Rule out the cheap explanations first: traffic mix, then releases. Only then dig into attribution, which tells you which team owns the fix.
How deep to go, by level. Go one level deeper than you are interviewing for if time allows.
LevelWhat a strong answer adds
JuniorKnows lab versus field, names LCP, INP, CLS with their thresholds, and suggests the web-vitals library with a beacon.
MidFlushes on visibilitychange, tags by route and device, reads p75 instead of the mean, and knows CrUX is a 28-day Chrome-only view.
SeniorUses attribution to point at causes, handles SPAs and soft navigations, samples per session, compares releases during rollout, and addresses privacy and cost.
StaffSets per-segment targets and alerting policy across teams, ties speed to business metrics with experiments, and owns the data quality of the pipeline.

Check your understanding · Exercise 7

An interviewer asks how you will make sure a new image-heavy product page stays fast after launch. Which points belong in a strong answer? Select all that apply.

Summary

  • Lab data finds problems you can reproduce; field data from real users tells you which problems people actually have. You need both, and INP only exists in the field.
  • Use the web-vitals library with its attribution build: it gets the metric rules right and tells you why a number is bad, not just that it is.
  • Most vitals are final only when the page is hidden, so queue samples and flush them on visibilitychange with sendBeacon or fetch with keepalive. Never rely on load or unload.
  • Navigation Timing, Resource Timing (with Timing-Allow-Origin), Server-Timing, Long Animation Frames, and User Timing explain where the time went.
  • Tag every sample with a few low-cardinality dimensions: route, device class, network, country, browser, release, navigation type. Watch traffic share, or mix shift will fool you.
  • Read percentiles and histograms, never the mean. p75 is the goal, p95 the early warning, and percentiles can only be merged from raw data, histograms, or sketches.
  • Alert on relative change per segment against a baseline, with a minimum sample size, and compare releases during rollout.
  • Sample per session, store the rate, and collect groups, not people: route patterns, no IPs, no user ids.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

How would you measure the real-world performance of a large single-page app?

Question 2

Why do Core Web Vitals use the 75th percentile and not the average?

Question 3

Your global p75 LCP got 300 ms worse this week, but no code changed. How do you investigate?

Question 4

What is the difference between CrUX and your own RUM, and when would you use each?

Question 5

How do you keep RUM cheap and privacy-safe on a site with 50 million page views a day?

Practice it

These design problems lean on real-user monitoring (rum).

References

Read next