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.
| Question | Lab (Lighthouse, WebPageTest, CI checks) | Field (RUM, CrUX) |
|---|---|---|
| Who loads the page? | A script on one machine | Every real user (or a sample) |
| Is the result repeatable? | Yes, mostly: good for catching regressions before release | No: 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 script | All of them, as users really see them |
| Can it explain why? | Yes: full trace, filmstrip, waterfall | Partly: only what you chose to collect |
| When do you get it? | Before release | After 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.
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
- Collect in the page: a small script observes Web Vitals and Performance API entries as the browser records them.
- Send at the right moment: samples are queued and flushed in one request when the page is hidden, with
navigator.sendBeacon()orfetchwithkeepalive. - 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.
- 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.
- Aggregate and read: queries compute percentiles and histograms per segment per hour or day, which feed dashboards and alerts.
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.
{
"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
}
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.
load misses INP and CLS completely, and a flush on visibilitychange catches them.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);
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.
| Metric | Attribution fields worth storing | The question it answers |
|---|---|---|
| LCP | target (the LCP element's selector) and url, plus timeToFirstByte, resourceLoadDelay, resourceLoadDuration, elementRenderDelay | Was it the server, a late-discovered image, a heavy image, or render-blocking work? |
| INP | interactionTarget, interactionType, inputDelay, processingDuration, presentationDelay, loadState | Which control is slow, and is the time spent waiting, in handlers, or painting? |
| CLS | largestShiftTarget, largestShiftValue, largestShiftTime, loadState | Which 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.
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 });
}
Navigation Timing: where the first seconds went
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.
Server-Timing is the next thing to read, not the network.| Phase | Computed as | A big value usually means |
|---|---|---|
| Redirects | redirectEnd - redirectStart | A chain like http to https to www; fix the links |
| Service worker and cache | domainLookupStart - fetchStart | A slow service worker start or cache lookup |
| DNS | domainLookupEnd - domainLookupStart | First visit or a cold DNS cache; consider fewer origins |
| Connect and TLS | connectEnd - connectStart | No connection reuse, or a far-away server; a CDN helps |
| Server wait | responseStart - requestStart | Slow server work or a cache miss at the CDN |
| Download | responseEnd - responseStart | A 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/2 200
content-type: text/html
server-timing: cdn-cache;desc="MISS", db;dur=212, render;dur=148
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, andperformance.measure()to get a duration between two marks. Observe themarkandmeasureentry 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 theelemententry type. It is supported in Chromium-based browsers only, so treat it as extra detail, not a metric for everyone. - Event Timing (the
evententry 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-frametype, Chrome 123 and later) report frames that took over 50 ms, withblockingDurationand ascriptslist 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
- Collect samples into an in-memory queue as callbacks fire. Do no network work inside the callbacks.
- Listen for
visibilitychange. Whendocument.visibilityStatebecomeshidden, serialize the whole queue into one body and send it. - Send with
navigator.sendBeacon(url, body)orfetch(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. - Also listen for
pagehideas a backup for older Safari versions, and make the flush idempotent so a second call sends nothing. - Never use the
unloadevent. It is unreliable on mobile, and it stops the page from entering the back/forward cache, which makes the very next navigation slower.
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
asyncor a dynamicimport(). Thanks tobuffered: true, it still sees early entries. - Keep it small:
web-vitalswith 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?
The INP value is reported once per page view, at the end of the visit. If the team tags samples with the route at load time, every slow interaction on later routes (a heavy filter panel on /search, say) is counted against /.
Confirm it by tagging each INP sample with the route that was active at attribution.interactionTime, or simply the route at report time and the interaction target. The slow interactions will cluster on another route's controls.
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
| Dimension | How you get it | Watch out for |
|---|---|---|
| Page type | The route pattern from your router, like /hotels/[city] | Full URLs explode into millions of values and leak data |
| Form factor | Mobile, tablet, or desktop from the user agent at the collector | Tablets in desktop mode report a desktop user agent |
| Device class | navigator.deviceMemory and navigator.hardwareConcurrency, bucketed into low, mid, high | deviceMemory is Chromium-only and rounded (for example 2, 4, 8); Safari users land in unknown |
| Network | navigator.connection.effectiveType, rtt, downlink, saveData | The Network Information API is Chromium-only; values are rounded estimates and effectiveType tops out at 4g |
| Country | Added at the collector from the IP, or from a CDN header such as CloudFront-Viewer-Country | Drop the IP after the lookup; VPNs and corporate proxies blur it |
| Browser and version | Parsed from the user agent at the collector | Not every browser supports every metric, so compare like with like |
| Release | A build id baked into the bundle | Without it, you cannot tie a regression to a deploy |
| Navigation type | navigationType from the library | Back/forward cache restores and prerenders are near-instant and pull the numbers down |
| Experiment variant, logged in | From your app state | Make 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.
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.
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.
| Statistic | The question it answers | Use it for |
|---|---|---|
| Mean | If 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 |
| p75 | Are at least three in four visits good? | Core Web Vitals pass or fail; team goals |
| p95, p99 | How 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.
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.
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?
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:
- 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.
- Second row, the shape: the good, needs improvement, and poor shares for each metric over 30 days, as stacked bars.
- Third row, the breakdown: p75 by page type and by device class, with sample counts and traffic share next to each.
- Fourth row, the why: the LCP parts and INP phases as stacked bars over time, and the top LCP elements and interaction targets.
- Release markers drawn on every time chart, so a step change lines up with a deploy.
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?
Probably both, for different populations. CrUX covers only opted-in Chrome users on desktop and Android over 28 days. Your RUM might include iOS Safari (often on faster phones), count back/forward cache restores as fast loads, or sample differently.
Filter your RUM to Chrome on desktop and Android over the last 28 days, and compare again. If the numbers still disagree, check that your script loads early enough: a visitor who gives up and leaves before your RUM code runs sends nothing, so RUM that loads late loses exactly the worst visits.
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.
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 };
- 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
| Symptom | Likely cause | Fix |
|---|---|---|
| INP and CLS sample counts are far below LCP | Data is sent on load or beforeunload, not on hide | Flush on visibilitychange to hidden, with pagehide as a backup |
| p75 drops sharply after a release that changed nothing | The script now loads later and misses slow visits that leave early | Load RUM earlier, and track the share of page views with a sample |
| p75 improves when a new feature launches | The feature enabled the back/forward cache, and instant restores pulled the numbers down | Report navigationType and chart restores separately |
| One country's numbers look implausibly fast | Bots or synthetic monitors with a real browser are counted | Filter known bots and your own synthetic checks at the collector |
| The bill doubled | A new tag with high cardinality, or sampling turned off for testing | Cap tag values at the collector; alert on rows per day |
| Ad blockers drop a noticeable share of beacons on some audiences | The endpoint is on a vendor domain or has a blocked path like /analytics | Send 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-vitalslibrary 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-vitalsplus 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".
| Level | What a strong answer adds |
|---|---|
| Junior | Knows lab versus field, names LCP, INP, CLS with their thresholds, and suggests the web-vitals library with a beacon. |
| Mid | Flushes on visibilitychange, tags by route and device, reads p75 instead of the mean, and knows CrUX is a 28-day Chrome-only view. |
| Senior | Uses attribution to point at causes, handles SPAs and soft navigations, samples per session, compares releases during rollout, and addresses privacy and cost. |
| Staff | Sets 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-vitalslibrary 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
visibilitychangewithsendBeaconorfetchwithkeepalive. Never rely onloadorunload. - 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?
Add the web-vitals attribution build to report LCP, INP, CLS, and TTFB, plus User Timing marks for the app's key moments, like a route becoming usable. Queue samples and send them in one beacon when the page becomes hidden, to a first-party endpoint.
Tag each sample with the route where it happened, device class, network, release, and navigation type; the collector adds country and drops the IP. Store raw rows or mergeable sketches, sample per session, and read p75 per segment with release markers. For route changes, add a User Timing measure that works in every browser, and turn on reportSoftNavs in web-vitals 6 to get per-route vitals from Chrome 151 and later.
Question 2
Why do Core Web Vitals use the 75th percentile and not the average?
Load times are right-skewed: most visits are fast and a long tail is slow. The mean is pulled around by a few extreme outliers and cannot say how many users had a bad visit.
p75 says that three in four visits were at or under the value. It is strict enough that most users must be well served, and stable enough that a handful of broken networks cannot decide the result. Teams add p95 to watch the tail.
Question 3
Your global p75 LCP got 300 ms worse this week, but no code changed. How do you investigate?
First check the traffic mix: the share of mobile, of low-end devices, and of each country. A campaign that brings slower devices makes the total worse while every segment stays flat. Then check the share of back/forward cache restores and the browser versions reporting, both of which move the global number.
If the mix is stable, split by route and look at the LCP parts. A rise in TTFB with Server-Timing showing CDN misses points at the cache; a rise in resource load duration points at images or a third party. Check deploys on the backend and CDN config too, since "no code changed" often means no frontend code changed.
Question 4
What is the difference between CrUX and your own RUM, and when would you use each?
CrUX is Google's public field dataset from opted-in Chrome users on desktop and Android, reported as a p75 over 28 days per origin and popular URL. It is free and it is what Search uses, but it is slow to react, has no iOS data, and knows nothing about your routes, releases, or causes.
Your own RUM sees every browser you instrument, reacts in minutes, carries your tags and attribution, and can be joined with business data. Use CrUX as the external scoreboard and your RUM to find and fix problems.
Question 5
How do you keep RUM cheap and privacy-safe on a site with 50 million page views a day?
Sample per session with a hash of a random session id: a few percent on busy pages, 100% on rare important ones, with the rate stored on each row. Keep tags low-cardinality, and store histograms or sketches for long-term history.
Send route patterns not URLs, no user ids, scrub selectors, drop the IP after the country lookup, set a retention period, and follow your consent rules per region.
Practice it
These design problems lean on real-user monitoring (rum).
- Design a client-side analytics SDKMedium
- Design a frontend logging and error SDKMedium
- Design an LLM chat interface with streaming answersMedium
- Design a plan to make a slow e-commerce site fastMedium
- Design an A/B testing system for the frontendHard
- Design NetflixHard
- Design a video call web app like Google MeetHard
References
- web-vitals library (GitHub) The standard way to collect LCP, INP, and CLS in your own RUM, including the attribution build.
- Performance APIs (MDN) Overview of PerformanceObserver and every entry type.
- Resource Timing: Timing-Allow-Origin (MDN) How cross-origin resources opt in to detailed timing.
- Server-Timing (MDN) Sending backend timings to the browser.
- Long Animation Frames API (Chrome for Developers) Finding the scripts behind slow interactions.
- Chrome UX Report overview (Chrome for Developers) What CrUX collects, from whom, and how it aggregates.
- Why lab and field data can be different (web.dev) The common reasons the two disagree.
Read next
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Logging in the browserSending small, structured records of what happened in each user's browser to your own servers, in batches that survive the tab closing, sampled and scrubbed so they stay cheap and safe: the only way to see bugs that never happen on your machine.
- Error trackingCatching the errors that happen in users' browsers, decoding minified stacks, grouping thousands of reports into a few issues, and alerting when a release makes things worse: how you learn the app is broken before support does.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Performance budgets and monitoringAgreed limits on what a page may cost (kilobytes of JavaScript, LCP, INP) that CI checks on every pull request and real-user monitoring watches in production, so speed stops getting worse one small change at a time.