Tracking Font CLS with the web-vitals Library

This guide is part of the Real-User Monitoring for Web Fonts (RUM) topic, itself under the Font Performance Monitoring & Auditing area.

A lab CLS score from Lighthouse tells you what happened on one machine, on one network, in one run. It does not tell you what your font swap is doing to the 40th percentile of real visitors on a throttled mobile connection in a region far from your CDN edge. To answer that question you need field data, and the fastest way to get accurate, low-overhead field CLS numbers — with enough detail to blame a specific font — is Google's web-vitals JavaScript library combined with its attribution build.

Prerequisites

Before wiring this up you should already have:

  • A font-loading strategy that reserves space, ideally with size-adjust or ascent-override fallback metrics, as described in Debugging Font-Related Layout Shift (CLS).
  • A font-display value chosen deliberately — see font-display Values: swap, optional, fallback Explained — since swap is by far the most common source of font-driven CLS.
  • An analytics or logging endpoint that can accept a sendBeacon payload, or an existing RUM vendor integration.
  • web-vitals installed: npm install web-vitals.

The web-vitals library wraps the browser's native PerformanceObserver for layout-shift, largest-contentful-paint, and other entry types, and adds session-windowing logic so the CLS number it reports matches what Chrome's Core Web Vitals report and PageSpeed Insights show. Rolling your own observer without this windowing logic is a common way to produce a CLS metric that silently diverges from what Search Console shows.

Implementation

Use the attribution build of web-vitals, which exposes entry.sources on every layout-shift event that contributed to the final score. Each source has a node reference (when available) and previousRect/currentRect geometry, which is exactly what you need to tell a font-driven shift apart from an image, an ad slot, or a late-loading banner.

// font-cls-tracking.js
import { onCLS } from 'web-vitals/attribution';

// A CSS class you toggle once your webfont is confirmed active,
// e.g. via document.fonts.ready or a FontFaceSet check.
const FONT_LOADED_CLASS = 'fonts-loaded';
let fontSwapWindowEnd = 0;

function markFontSwap() {
  // Layout shifts inside this window are highly likely to be font-caused.
  fontSwapWindowEnd = performance.now() + 500;
}

document.fonts.ready.then(() => {
  document.documentElement.classList.add(FONT_LOADED_CLASS);
  markFontSwap();
});

onCLS((metric) => {
  let fontAttributedShift = 0;

  for (const entry of metric.attribution?.largestShiftEntry
    ? [metric.attribution.largestShiftEntry]
    : []) {
    const withinSwapWindow = entry.startTime <= fontSwapWindowEnd;
    const touchesText = entry.sources?.some((source) =>
      source.node instanceof Element &&
      getComputedStyle(source.node).fontFamily.length > 0 &&
      withinSwapWindow
    );

    if (touchesText) {
      fontAttributedShift += entry.value;
    }
  }

  const payload = JSON.stringify({
    name: 'CLS',
    value: metric.value,
    fontAttributedShift,
    id: metric.id,
    navigationType: metric.navigationType,
  });

  navigator.sendBeacon('/analytics/web-vitals', payload);
});

Line by line: onCLS from the attribution entry point (not the default web-vitals import) is required — the standard build strips attribution to keep the bundle small. markFontSwap opens a 500ms window starting when document.fonts.ready resolves, because a font swap and its resulting reflow happen within a single frame or two of that promise settling, not seconds later. Inside the onCLS callback, metric.attribution.largestShiftEntry gives you the single biggest layout-shift entry that contributed to the session's CLS value — this is usually enough signal without iterating every entry. For each source node touched by that shift, checking getComputedStyle(source.node).fontFamily is a heuristic: if the shifted element renders text and the shift falls inside the font-swap window, it is overwhelmingly likely to be the font swap resizing the box, not an unrelated late-loading image. The beacon fires on visibility-change/page-hide via web-vitals's built-in lifecycle handling, so you get the metric even if the user closes the tab before a fetch would complete.

This ties directly into the work described in Track Font Load Time with PerformanceObserver: that guide gets you the timing of the font resource; this pattern gets you the layout consequence of that timing, in the field, at scale.

Attributing CLS to a font swap A five-step process diagram showing how a layout shift gets attributed to a font swap in the field, from fonts.ready to the analytics beacon. Attributing CLS to a font swap 1 fonts.ready fires open swap window 2 Layout shift occurs PerformanceObserver 3 Check entry.sources node + timing 4 Tag as font-caused within window 5 sendBeacon report fontAttributedShift
The field pipeline from font load to a tagged CLS beacon.

Defensive Variant: Multiple Shift Sources and Session Windows

CLS is a sum over a bounded session window, not a single event, so a page can accumulate small font-caused shifts across several swaps — for example a heading font resolving first, then a body font, then an icon font. The single-largestShiftEntry heuristic above misses shifts that are individually small but numerous. A more defensive version inspects every entry that contributed to the metric's final session window, not just the largest one:

import { onCLS } from 'web-vitals/attribution';

const FONT_SWAP_WINDOW_MS = 500;
const fontSwapTimestamps = [];

['heading', 'body', 'icon'].forEach((token) => {
  document.fonts.load(`1em "${token}-font"`).finally(() => {
    fontSwapTimestamps.push(performance.now());
  });
});

function isWithinAnySwapWindow(startTime) {
  return fontSwapTimestamps.some(
    (t) => startTime >= t && startTime <= t + FONT_SWAP_WINDOW_MS
  );
}

onCLS((metric) => {
  const entries = metric.entries ?? [];
  let fontShiftTotal = 0;
  let otherShiftTotal = 0;

  for (const entry of entries) {
    const causedByFont = entry.sources?.some(
      (s) => s.node instanceof Element && isWithinAnySwapWindow(entry.startTime)
    );
    if (causedByFont) {
      fontShiftTotal += entry.value;
    } else {
      otherShiftTotal += entry.value;
    }
  }

  navigator.sendBeacon(
    '/analytics/web-vitals',
    JSON.stringify({
      name: 'CLS',
      total: metric.value,
      fontShiftTotal,
      otherShiftTotal,
      pctFromFonts: metric.value ? fontShiftTotal / metric.value : 0,
    })
  );
}, { reportAllChanges: true });

{ reportAllChanges: true } makes onCLS fire on every session-window update rather than only once at page-hide, which matters for single-page apps where a font can swap long after the initial paint (for example, after a client-side route change loads a new heading face). Iterating metric.entries — the full set of layout-shift entries in the metric's session window, not just the worst one — avoids undercounting pages where several smaller font swaps each contribute a little CLS. Dividing fontShiftTotal by metric.value gives you a percentage you can chart directly: "38% of this page's CLS budget came from fonts" is a far more actionable number for a team than the aggregate score alone.

CLS contribution by cause A bar chart comparing layout shift score contributed by fonts versus images and ads versus other dynamic content, with the font bar highlighted. CLS contribution by cause Fonts 0.062 Images 0.024 Ads/embeds 0.014 CLS score
Example session-window breakdown across shift causes.

What the Attribution Object Actually Gives You

It helps to know exactly which fields the attribution build adds on top of the base CLSMetric, because it's easy to reach for a field that doesn't exist and silently get undefined in production. The table below covers the fields used above plus a few you'll want once you go past a proof-of-concept.

Field Type What it tells you
metric.value number Final CLS score for the reporting period (matches CrUX/Search Console).
metric.entries LayoutShift[] Every layout-shift entry counted in the current session window.
metric.attribution.largestShiftEntry LayoutShift The single entry that contributed the most to the score.
metric.attribution.largestShiftTarget string | undefined A CSS selector for the element most responsible, when resolvable.
entry.sources[].node Element Live DOM reference at report time — may be null if the node was later removed.
entry.sources[].previousRect / currentRect DOMRectReadOnly Bounding boxes before/after the shift, useful for confirming a height change matches your fallback-to-webfont metric delta.

Two things worth internalizing from this table: largestShiftTarget is a convenience string built from the DOM at the time the shift happened, and it can go stale or come back undefined for detached nodes — treat it as a debugging aid, not something to key business logic on. And previousRect/currentRect are the pieces that let you go one step further than "this was probably the font" and actually prove it: if the height delta between the two rects matches the line-height difference between your fallback stack and the loaded webfont (the kind of number you'd compute when tuning size-adjust), you have direct confirmation rather than a timing-window heuristic.

Verification

Confirm the pipeline end-to-end before trusting it in production:

  1. Open Chrome DevTools → Performance panel, enable "Screenshots" and "Layout Shift Regions", and record a reload of a page where a webfont swap is visible. Confirm a Layout Shift entry appears in the timeline coincident with the visual font swap.
  2. In the Console, run new PerformanceObserver(list => console.log(list.getEntries())).observe({type: 'layout-shift', buffered: true}) and reload — verify the entries you see match what your onCLS handler receives in metric.entries.
  3. Check the Network tab for the sendBeacon request to /analytics/web-vitals firing on tab-hide (switch tabs, don't just close DevTools) and inspect the payload for a non-zero fontShiftTotal on a page you know shifts.
  4. Cross-check the aggregate CLS your beacon reports against Chrome UX Report or Search Console's Core Web Vitals data for the same URL over the same date range — they should be within a small tolerance, confirming your session-window logic matches Google's.

Common Pitfalls

  • Importing web-vitals instead of web-vitals/attribution. The default build has no attribution object, so entry.sources and largestShiftEntry are undefined and every shift looks unattributed. Always import from the /attribution subpath when you need to blame a specific cause.
  • Using a swap window that's too short. A 50–100ms window can miss the reflow if the browser is under load and the paint is delayed a frame or two; 400–600ms is a safer default without risking false positives from unrelated later shifts.
  • Measuring only largestShiftEntry on pages with several web fonts. A heading font, a body font, and an icon font each swapping separately can each contribute a small, individually-forgivable shift that sums to a failing CLS score; sum the full entries array as shown in the defensive variant.
  • Forgetting reportAllChanges: true on SPA routes. Without it, onCLS only reports once per page load, so a font swap triggered by a later client-side navigation never gets attributed, and your dashboard will show falsely low CLS for those routes.
  • Not correlating with font-display. If you use font-display: optional as covered in FOUT vs FOIT Mitigation, the browser may never swap at all on a slow connection, so a "zero font CLS" reading can just mean the fallback stuck — check your load-success rate alongside the CLS number, not instead of it.

Frequently Asked Questions

Does this add meaningful overhead to page load? No. web-vitals is roughly 1–2KB gzipped for the base metrics and a few hundred bytes more for the attribution build, and it relies entirely on the native PerformanceObserver, which is passive instrumentation the browser is already computing for its own Core Web Vitals reporting. The sendBeacon call is fire-and-forget and does not block navigation.

Can I get this data without any custom code, using a RUM vendor? Most commercial RUM tools (and the web-vitals library's own basic build) already report an aggregate CLS number. What they usually don't give you out of the box is the font-specific slice — you need the attribution build and the swap-window correlation shown here to separate "CLS from fonts" from "CLS from images/ads/dynamic content" without manually triaging session recordings.

How does this differ from the DevTools-based debugging workflow? Finding the Font Causing CLS in DevTools is a manual, one-page-at-a-time investigation you run locally to diagnose a known problem. This pattern is the always-on field instrumentation that tells you whether a problem exists across your real traffic, and how large it is in aggregate, before you go looking for it in DevTools.

What if I've already fixed CLS with metric-matched fallbacks — is this still worth deploying? Yes, as a regression guard. Once you've applied size-adjust/ascent-override tuning as in Accessible Fallback Font Stacks & CLS Prevention, fontAttributedShift should trend to near zero. Alert on it creeping back up — a font-vendor update, a new weight added without matching metrics, or a CDN outage triggering fallback-to-swap can all reintroduce the shift silently.

Should I send this on every page view, or sample it? For most sites, send it on every view — the payload is a handful of bytes and sendBeacon is asynchronous and non-blocking, so there is no user-facing cost to full collection. Sampling only becomes worthwhile once you're at a traffic volume where the analytics endpoint itself, not the client, is the bottleneck; at that point sample at the network-collection layer (drop a percentage of beacons server-side) rather than in the client script, so you don't bias the sample toward faster or slower devices by conditioning the sampling decision on anything client-side.

Font-attributed CLS vs budget A meter showing font-attributed cumulative layout shift measured at 0.062 against a 0.1 CLS budget threshold. Font-attributed CLS vs budget 0 0.1 CLS 0.062 measured 0.1 budget
Measured font CLS against the 0.1 good threshold.

Related