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-adjustorascent-overridefallback metrics, as described in Debugging Font-Related Layout Shift (CLS). - A
font-displayvalue chosen deliberately — see font-display Values: swap, optional, fallback Explained — sinceswapis by far the most common source of font-driven CLS. - An analytics or logging endpoint that can accept a
sendBeaconpayload, or an existing RUM vendor integration. web-vitalsinstalled: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.
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.
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:
- 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 Shiftentry appears in the timeline coincident with the visual font swap. - 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 youronCLShandler receives inmetric.entries. - Check the Network tab for the
sendBeaconrequest to/analytics/web-vitalsfiring on tab-hide (switch tabs, don't just close DevTools) and inspect the payload for a non-zerofontShiftTotalon a page you know shifts. - 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-vitalsinstead ofweb-vitals/attribution. The default build has noattributionobject, soentry.sourcesandlargestShiftEntryareundefinedand every shift looks unattributed. Always import from the/attributionsubpath 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
largestShiftEntryon 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 fullentriesarray as shown in the defensive variant. - Forgetting
reportAllChanges: trueon SPA routes. Without it,onCLSonly 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 usefont-display: optionalas 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.