Measuring CLS in the Field With web-vitals.js
Lab tools tell you whether a page can be stable. Only field data tells you whether it is. A static site's real CLS is decided by things a scripted run never does: scrolling into a lazily loaded embed, accepting a consent dialog, loading on a cheap Android device where the font race resolves differently every time.
This guide sets up real-user CLS collection with the web-vitals library's attribution build, a small edge endpoint to receive it, and the query that turns a 75th-percentile score into the name of an element you can fix. It closes out Cumulative Layout Shift Fixes for Static Sites.
Prerequisites
- A static site you can add one small script to, plus somewhere to receive beacons — a Worker, a serverless function, or an existing analytics pipeline.
- Enough traffic for percentiles to settle: a few hundred views per template per week.
- The lab fixes already applied, so what you collect is the residue rather than the obvious.
What to Collect
The Recipe
1. Add the attribution build
<script type="module">
import { onCLS, onLCP, onINP } from 'https://unpkg.com/web-vitals@4/dist/web-vitals.attribution.js';
const send = (metric) => {
const body = JSON.stringify({
name: metric.name,
value: Math.round(metric.value * 1000) / 1000,
rating: metric.rating,
route: document.querySelector('meta[name="page-template"]')?.content || location.pathname,
nav: metric.navigationType,
target: metric.attribution?.largestShiftTarget || metric.attribution?.element || null,
});
navigator.sendBeacon('/api/vitals', body);
};
onCLS(send);
onLCP(send);
onINP(send);
</script>
Self-host the file rather than loading it from a CDN in production — it removes a third-party connection from the critical path, exactly the argument made in Self-Hosting Analytics to Cut Third-Party Requests.
The route field matters more than it looks. Reporting raw pathnames on a 500-page site gives you 500 sparse buckets; reporting the template gives you five dense ones, and templates are what you actually fix. Stamp the template name into a meta tag at build time.
2. Receive the beacon at the edge
// functions/api/vitals.js — Cloudflare Pages Function / Worker
export async function onRequestPost({ request, env }) {
const data = await request.json().catch(() => null);
if (!data || typeof data.value !== 'number') {
return new Response('bad request', { status: 400 });
}
await env.VITALS.writeDataPoint({
blobs: [data.name, data.route, data.rating, data.target ?? ''],
doubles: [data.value],
indexes: [data.route],
});
return new Response(null, { status: 204 });
}
Return 204 with no body: the browser has already unloaded the page and nothing reads the response. Reject payloads that fail validation rather than storing them — a public endpoint will receive junk.
3. Query the 75th percentile per template
Core Web Vitals is assessed at p75, so that is the number to track:
SELECT blob2 AS template,
quantile(0.75)(double1) AS cls_p75,
count() AS samples
FROM vitals
WHERE blob1 = 'CLS' AND timestamp > now() - INTERVAL 7 DAY
GROUP BY template
ORDER BY cls_p75 DESC;
Then, for the worst template, group by the attribution target to get the element:
SELECT blob4 AS element, count() AS n, quantile(0.75)(double1) AS cls_p75
FROM vitals
WHERE blob1 = 'CLS' AND blob2 = 'article' AND timestamp > now() - INTERVAL 7 DAY
GROUP BY element ORDER BY n DESC LIMIT 10;
4. Alert on regression, not on absolute value
A static site's field CLS moves when a deploy changes markup. Compare the current 7-day p75 against the previous 7 days for the same template and alert on a relative jump, which catches regressions long before the absolute score crosses 0.1.
Tie the alert to deploys rather than to the clock. Record the build identifier in the beacon — a commit SHA stamped into a meta tag at build time costs nothing — and the query that says "template article got 40% worse" can also say "starting with build a91f3c2". That turns a weekly dashboard review into a same-day fix, and it is the field-data equivalent of the deploy-tagged budgets described in Measuring Build-Time Regressions in CI.
Give any new alert two weeks of shadow running before it can page anyone. Seasonal traffic shifts the device mix — a weekend skews mobile, a conference skews desktop — and a threshold tuned on a single quiet week will fire on the first busy Monday for reasons that have nothing to do with your markup.
Reading a Burst Correctly
The reported CLS is not the sum of every shift on the page — it is the largest burst, where a burst is a run of shifts with no more than one second between consecutive entries and no more than five seconds from first to last. The attribution build reports the largest shift inside that winning burst, which is why the element it names is the one to fix and the others usually are not.
The practical consequence is that a fix can look like it did nothing. If two elements shift inside the same burst and you fix the smaller one, the burst — and therefore the score — barely moves. Always fix the element attribution names, re-measure for a week, and expect the next worst element to appear in its place.
Measured Impact
Two weeks of collection on a 60-page documentation site, roughly 40,000 page views:
| Template | Lab CLS | Field p75 | Worst element (attribution) |
|---|---|---|---|
| Article | 0.01 | 0.14 | .comments — loaded on scroll, unreserved |
| Index | 0.00 | 0.02 | img.card-thumb — one legacy card without dimensions |
| Homepage | 0.00 | 0.11 | .consent — banner prepended in flow |
| Search | 0.02 | 0.03 | .results — reserved correctly |
Fixing the two elements the attribution named — a min-height on .comments and a fixed overlay for .consent — moved the article template from 0.14 to 0.02 and the homepage from 0.11 to 0.01 at p75 over the following fortnight. Neither change would have been prioritised from the lab numbers, which were already green.
Pitfalls & Rollback
- Reporting raw pathnames. Percentiles over hundreds of sparse URLs are noise. Group by template.
- Firing on
unload. That event is unreliable on mobile Safari;web-vitalsalready reports onvisibilitychange, so keep its default. - Ignoring
navigationType. Back/forward-cache restores behave differently from cold loads; keep the field so you can separate them rather than chasing a phantom regression. - Storing personal data. Nothing here needs identifiers. Keep the payload anonymous so the endpoint stays outside consent-management scope.
- Chasing the mean. CLS is assessed at p75; an average hides exactly the tail that fails the assessment.
- Rollback: remove the script tag. Collection stops immediately and nothing else on the page depends on it.
Conclusion
Field measurement is what closes the loop on layout stability: it finds the shifts your build never reproduces and, with the attribution build, names the element responsible. Add the script, receive beacons at the edge, group by template, and alert on week-over-week regression. Then feed what you find back into the reservation patterns in Fixing CLS From Late-Loading Embeds — the same pipeline also gives you real INP numbers, as covered in Measuring INP on Static Sites With Real-User Monitoring.
FAQ
Why is field CLS worse than my Lighthouse score?
Lighthouse loads the page once in a fixed viewport and stops recording after a few seconds. Real visits scroll, trigger lazily loaded embeds, run consent flows and last minutes. Anything that moves during that longer window counts in the field score and never appears in the lab run.
How much traffic do I need for the numbers to mean anything?
A few hundred page views per template per week is enough to see a 75th percentile stabilise. Below that, watch the raw entries and their attribution instead of the percentile, because a handful of slow sessions will dominate any aggregate.
Does the web-vitals script hurt performance?
The attribution build is about 3 kilobytes gzipped and runs entirely in idle time, reporting once per page via sendBeacon. It is one of the few third-party-shaped additions that does not measurably move the metrics it measures.
What is the difference between the standard and attribution builds?
The standard build reports a number. The attribution build also reports which element shifted the most, its previous and current rectangles, and the load state at the time, which is the difference between knowing a page is bad and knowing what to fix.
Should I sample or record every page view?
Record everything until the volume is inconvenient. CLS beacons are tiny, and full collection means a rare shift on a rare device is still visible. Sample only when the ingestion cost becomes real, and sample by session rather than by event so a session's score is never partial.
Related
- Parent: Cumulative Layout Shift Fixes for Static Sites — the fixes this data prioritises.
- Fixing CLS From Late-Loading Embeds — what the attribution usually names.
- Measuring INP on Static Sites With Real-User Monitoring — the same pipeline for interaction latency.
- Self-Hosting Analytics to Cut Third-Party Requests — keeping the collector off a third-party connection.
- Performance Optimization & Core Web Vitals for SSGs — how the three vitals fit together.