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

Field CLS collection pipeline A left-to-right flow. The browser runs the web-vitals attribution build, which reports a CLS value plus the largest shifted element on page hide. A sendBeacon request carries it to an edge function, which writes to an analytics store, which feeds a per-template 75th percentile dashboard. From a reader's device to a named element Browser web-vitals attribution build sendBeacon on visibilitychange ~400 byte payload Edge function validate + enrich write to store p75 by template + worst element per route Payload carries: value, rating, navigation type, route, element selector, largest shift rectangles Everything is anonymous — no identifiers, no cookies, one beacon per page view
The attribution build is what makes the pipeline actionable: without the element selector you learn that a template is unstable, not which node to reserve space for.

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.

How bursts decide the reported CLS A timeline over eight seconds with six layout shifts. Three shifts between 0.6 and 1.9 seconds form one burst totalling 0.05. Two shifts at 4.2 and 4.6 seconds form a second burst totalling 0.13, which is the reported score. A lone shift at 7.4 seconds scores 0.02 and is ignored. The worst burst wins — the rest is not reported burst 1 · fonts + images 0.02 + 0.02 + 0.01 total 0.05 0.6 s – 1.9 s burst 2 · comment thread 0.11 + 0.02 total 0.13 · reported 4.2 s – 4.6 s lone shift 0.02 7.4 s 0 s 4 s 8 s Attribution names the 0.11 entry inside burst 2 — fixing the 0.05 burst would not change the score at all
This is why field data so often points at one element: the metric deliberately reports the worst window, not the accumulated total.

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:

TemplateLab CLSField p75Worst element (attribution)
Article0.010.14.comments — loaded on scroll, unreserved
Index0.000.02img.card-thumb — one legacy card without dimensions
Homepage0.000.11.consent — banner prepended in flow
Search0.020.03.results — reserved correctly
Lab score versus field 75th percentile by template A paired bar chart. For each of four templates the lab score is near zero while the field 75th percentile is higher: article 0.01 versus 0.14, index 0.00 versus 0.02, homepage 0.00 versus 0.11 and search 0.02 versus 0.03. The gap is largest where content loads on scroll. The lab never sees what readers do 0.14 article 0.02 index 0.11 homepage 0.03 search lab (Lighthouse) field p75 (40k page views, 14 days)
Both templates with a large gap had something that only appears when a real person scrolls or clicks — the comment thread and the consent banner.

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-vitals already reports on visibilitychange, 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.