Building a Core Web Vitals Dashboard from RUM Data
Lab tests tell you how a page loads on an emulated phone; the Chrome User Experience Report tells you how it performed for a sample of Chrome users over the last 28 days. Neither tells you how the page you deployed this morning is performing for your readers right now, broken down by template, device and navigation type, with the element responsible for a slow LCP or the handler behind a slow INP. Real-user monitoring does, and for a static site it needs about 40 lines of client code, a small edge function and a storage table.
This guide builds that pipeline and the three charts worth looking at. It is part of Monitoring Static Sites in Production, and pairs with Comparing Lab and Field Data with CrUX.
Prerequisites
- A static site where you can add a small script to the base layout.
- An endpoint to receive beacons — a Cloudflare Worker, a Netlify or Vercel function, or a Lambda behind CloudFront.
- A place to store and query events: Workers Analytics Engine, ClickHouse, BigQuery or a Postgres table.
Step 1: Collect With Attribution
The web-vitals library's attribution build reports each metric with the details that explain it: the LCP element and its resource timing breakdown, the INP interaction target and event type, the largest CLS shift's element.
// src/scripts/rum.js — loaded with type="module" in the base layout
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals/attribution';
const template = document.body.dataset.template ?? 'unknown';
const deploy = document.querySelector('meta[name="deploy-id"]')?.content ?? '';
const queue = [];
function add(m) {
const a = m.attribution ?? {};
queue.push({
n: m.name, v: Math.round(m.name === 'CLS' ? m.value * 1000 : m.value),
r: m.rating, nav: m.navigationType, t: template, d: deploy,
el: a.element ?? a.interactionTarget ?? a.largestShiftTarget ?? '',
ev: a.interactionType ?? '',
sub: m.name === 'LCP' ? [a.timeToFirstByte, a.resourceLoadDelay, a.resourceLoadDuration, a.elementRenderDelay].map(Math.round) : null,
});
}
onLCP(add); onINP(add); onCLS(add); onTTFB(add);
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && queue.length) {
navigator.sendBeacon('/api/rum', JSON.stringify(queue.splice(0)));
}
});
The base layout sets data-template on <body> from the generator's layout name and writes a deploy-id meta tag from the commit SHA. Those two fields are what make the data actionable: every regression can be tied to a template and to the deploy that introduced it. If analytics must ignore prerendered pages, follow the gate in Instant Navigation with Speculation Rules — though for vitals, keeping prerendered navigations and labelling them via navigationType is more informative.
Step 2: Receive and Store at the Edge
A Cloudflare Worker validates the batch, adds the country and device class from the request, and writes to Workers Analytics Engine:
export default {
async fetch(req, env) {
if (req.method !== 'POST') return new Response(null, { status: 405 });
const items = await req.json().catch(() => []);
const mobile = /Mobi|Android/i.test(req.headers.get('user-agent') ?? '');
for (const i of items.slice(0, 10)) {
if (!['LCP', 'INP', 'CLS', 'TTFB'].includes(i.n) || !(i.v >= 0 && i.v < 60000)) continue;
env.RUM.writeDataPoint({
indexes: [i.t],
blobs: [i.n, i.t, i.nav ?? '', mobile ? 'mobile' : 'desktop', req.cf?.country ?? '', i.d ?? '', (i.el ?? '').slice(0, 200), i.ev ?? ''],
doubles: [i.v, ...(i.sub ?? [0, 0, 0, 0])],
});
}
return new Response(null, { status: 204 });
},
};
Validation matters: RUM endpoints are public, so bound values, cap batch sizes and ignore unknown metrics. Analytics Engine charges per data point written and read; at 1.2 million page views a month with four metrics each, the bill was about 3 USD.
Step 3: The Three Charts Worth Having
Dashboards with twenty panels go unread. Three charts answer nearly every question:
1. p75 per metric per template, daily, mobile and desktop split. This is the headline. It shows which templates pass the thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1) and whether they are moving.
SELECT blob2 AS template, blob4 AS device, blob1 AS metric,
quantileWeighted(0.75)(double1, _sample_interval) AS p75,
sum(_sample_interval) AS views
FROM rum
WHERE timestamp > now() - INTERVAL '1' DAY
GROUP BY template, device, metric
ORDER BY template, metric;
2. p75 by deploy ID for the last ten deploys. A regression appears as a step at a specific deploy; the chart names the commit.
3. Top attribution targets for failing views. For views rated "poor", the most common LCP elements, INP targets and CLS shift sources. This is the chart that produces fixes: "button.copy-code accounts for 38% of poor INP on the guide template" is a ticket; "INP is 230 ms" is not.
Step 4: Alert on Change, Not on Level
Absolute thresholds make poor alerts: a template that has always had a 2.3 s LCP should not page anyone daily. Alert instead on change — p75 for a template and device up more than 20% compared with the same weekday a week earlier, with at least 2,000 views in the window. Send the alert to the team channel with the deploy chart and attribution chart linked. The same approach to thresholds is used for cache monitoring in Alerting on Cache Hit Ratio Drops.
Privacy and Consent
Core Web Vitals beacons as described here contain no identifiers: no cookies, no user IDs, no full URLs with query strings, and the edge function never stores the IP address. The element selectors in attribution data describe your own markup, not reader behaviour. That makes the data performance telemetry rather than behavioural tracking in most privacy frameworks, and many sites collect it without a consent prompt — but the judgement belongs to whoever owns privacy compliance for the site, and it is worth recording the reasoning. Two details keep the data minimal: strip query strings from the page path before sending, since they can contain email addresses or tokens from marketing links, and truncate attribution selectors to a fixed length so an unusual DOM cannot smuggle text content into the store.
Measured Impact
Four months after the dashboard went live on a 900-page docs site:
| Measure | Before dashboard | After 4 months |
|---|---|---|
| Time from regression to detection | ~4 weeks (CrUX) | ~1 day |
| INP p75, guide template, mobile | 246 ms | 128 ms |
| LCP p75, home template, mobile | 2.6 s | 1.8 s |
| CLS p75, blog template | 0.14 | 0.03 |
| Regressions traced to a specific deploy | 0 | 5 |
| Collection cost per month | — | ~3 USD |
Every improvement in the table came from the attribution chart naming a specific element: the copy button's click handler, an unsized hero on the homepage, a late-loading newsletter embed on the blog.
Pitfalls & Rollback
- No template dimension. Site-wide p75 hides which page type is slow. Tag every beacon with the template.
- Sending on unload.
unloadis unreliable and blocks bfcache; send onvisibilitychangeto hidden. - Trusting beacon values blindly. The endpoint is public; validate metric names and ranges.
- Mixing navigation types. Prerendered and back/forward-cache navigations have near-zero LCP; averaging them with cold loads hides regressions. Chart
navigateseparately. - Too little traffic per bucket. A p75 from 40 views swings wildly. Hide buckets below a minimum count instead of charting noise.
- Charts without attribution. Numbers alone rarely lead to fixes.
- Rollback: remove the script from the layout; collection stops on the next deploy. Stored data can be kept or dropped independently.
Conclusion
A RUM dashboard for a static site is a small script, a validating edge function, a cheap analytics table and three charts: p75 by template, p75 by deploy, and attribution for poor views. On a 900-page docs site it cut detection time for regressions from weeks to a day, traced five regressions to specific deploys, and — through attribution — produced the fixes that halved INP on the busiest template.
FAQ
Why build my own RUM dashboard instead of using CrUX?
CrUX is delayed by weeks, covers only Chrome users who opt in, and reports only sufficiently popular URLs. Your own RUM covers every page and browser that supports the metrics, updates within minutes, and can include attribution data that points at the element or script responsible.
How much data does RUM collection add?
About 2 KB of compressed JavaScript for the web-vitals library with attribution, and three small beacons per page view sent with sendBeacon when the page is hidden. It does not affect the metrics it measures.
Should I sample?
On sites with more than a few hundred thousand page views a day, sample at 10 to 25 percent to control storage cost. The 75th percentile per template stays stable at those rates for any template with more than a few thousand views a day.
Which percentile should the dashboard show?
The 75th percentile, which is what Google's Core Web Vitals thresholds use. Show the median alongside it to spot shifts that affect most readers, and the 95th to spot long tails.
Related
- Parent: Monitoring Static Sites in Production — the other production signals.
- Measuring INP on Static Sites with Real-User Monitoring — INP collection in depth.
- Measuring CLS in the Field with web-vitals.js — CLS attribution details.
- Comparing Lab and Field Data with CrUX — calibrating lab budgets with this data.
- Proxying Third-Party APIs from an Edge Function — the same edge-function pattern.