Finding the Font Causing CLS in DevTools
This guide pinpoints the single web font whose swap is inflating your Cumulative Layout Shift. It is the narrow, hands-on companion to Debugging Font-Related Layout Shift, and sits within the Font Performance Monitoring & Auditing area. You already know CLS is too high; you need the name of the font file and the DOM node responsible so you can apply a metric override.
Problem statement
CLS reported as a single number (say 0.21) tells you nothing about which of your five @font-face declarations caused it. A page may load body text, a heading face, an icon font, and a monospace code face; only one of them may have mismatched metrics against its fallback. The layout-shift PerformanceObserver exposes entry.sources[] — an array of the actual nodes that moved — which is the fastest way to map a shift back to a font, faster than reading source and guessing which declaration is missing a size-adjust.
The difficulty compounds once a page ships more than one web font. A shift score of 0.21 could be one font growing by 40px on a single hero heading, or four fonts each contributing roughly 0.05 across scattered paragraphs. Those two scenarios need completely different fixes — a single ascent-override versus a stack-wide review — and you cannot tell them apart from the aggregate number alone. This guide builds the attribution workflow that separates them, then verifies the diagnosis independently in three different DevTools panels so you never ship a fix for the wrong font.
Prerequisites
- Chrome or Edge 84+ (the
entry.sourcesarray landed in Chrome 84; thelayout-shifttype itself in 77). - The page served over a connection you can throttle (DevTools "Slow 4G") so the swap actually happens after paint.
- A way to run JS on the page — the Console is enough; no build step required.
- Familiarity with the page's
@font-facestack; if you have not already inventoried it, the metric-matching fix you will apply lives in Fallback Font Stack Design.
Implementation
Paste this into the DevTools Console before reloading (or ship it temporarily), then reload with "Slow 4G" throttling enabled so the fallback paints first.
layout-shift observer logging value + source node attribution
const shifts = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // skip user-driven shifts
for (const source of entry.sources) {
const before = source.previousRect;
const after = source.currentRect;
const grew = after.height - before.height; // +ve => box got taller
shifts.push({
score: Number(entry.value.toFixed(4)),
node: source.node, // the element that moved
family: source.node
? getComputedStyle(source.node).fontFamily
: '(detached)',
heightDelta: Math.round(grew), // px the box grew
topMoved: Math.round(after.top - before.top),
});
}
}
console.table(shifts);
});
observer.observe({ type: 'layout-shift', buffered: true });
After the page settles, console.table prints one row per moved node. Read it like this:
score— the shift's contribution to CLS. Sort descending; the top row is your worst offender.node— click it in the table to reveal the element in the Elements panel. This is the moved DOM node.family— the computedfont-familyof that node. The web font named here (not the fallback) is your suspect.heightDelta— the critical signal. A positive delta withtopMovednear0means the node's own box grew taller in place — the textbook signature of a font swap (taller line boxes in the web font). A node withheightDelta ≈ 0but a largetopMovedwas merely pushed down by the real culprit above it.buffered: true— ensures shifts that fired before the observer attached are still delivered, so your scripted total matches the DevTools CLS.
The annotated logic: each source carries previousRect and currentRect (DOMRects). Subtracting heights isolates the node that changed size from the nodes that only translated. Font swaps change size; the pushed siblings only translate. That distinction is what turns a list of "things that moved" into a single root cause.
Worked example: three fonts, one shift budget
Consider a page with three @font-face rules: Inter for body copy, Sora for headings, and an icon font (Line Icons) used only in the navigation. Lighthouse reports CLS 0.184 — over the "poor" threshold of 0.25's neighbor but still well past the 0.1 "needs improvement" line. Running the observer above produces:
| score | node | family | heightDelta | topMoved |
|---|---|---|---|---|
| 0.121 | h1.hero-title |
Sora, system-ui | +34 | +1 |
| 0.048 | p.hero-sub |
Inter, sans-serif | 0 | +34 |
| 0.011 | footer |
Inter, sans-serif | 0 | +34 |
| 0.004 | nav svg |
Line Icons | +2 | 0 |
Sorted by heightDelta, h1.hero-title is unambiguously the cause: it grew 34px in place while topMoved stayed near zero. The hero-sub and footer rows share the same +34 topMoved because they were both pushed down by the heading — they are victims, not causes, even though their individual scores are non-trivial. The icon font's +2 delta is real but small enough that fixing the heading first resolves 90% of the CLS budget; the icon font becomes a follow-up captured separately in Fixing Layout Shift from Icon Fonts.
This is the general pattern for multi-font pages: group rows by family, sum score per family, but decide causation using heightDelta, not the per-family sum. A font can accumulate a high summed score purely by being on many pushed-down victim nodes while never itself being the node that grew.
Attributing a shift when one family has multiple weights
getComputedStyle(node).fontFamily only returns the CSS family name — it cannot tell you which @font-face block matched, which matters when a family declares separate files per weight (font-weight: 400 vs 700, each with its own size-adjust). To resolve the actual matched face, cross-reference document.fonts:
function matchedFace(node) {
const cs = getComputedStyle(node);
const family = cs.fontFamily.split(',')[0].trim().replace(/["']/g, '');
const weight = cs.fontWeight;
const style = cs.fontStyle;
for (const face of document.fonts) {
if (
face.family.replace(/["']/g, '') === family &&
face.status === 'loaded' &&
Number(face.weight) === Number(weight) &&
face.style === style
) {
return face; // has .family, .weight, .style — no direct URL, but identifies the exact @font-face
}
}
return null;
}
Call matchedFace(source.node) inside the observer loop and log the result alongside family. This distinguishes "Sora 700 (headings)" from "Sora 400 (body)" when both are declared — critical because each weight typically needs its own size-adjust calculation, and fixing the wrong weight's @font-face leaves the shift untouched.
Defensive variant
The basic version throws if source.node has been removed from the DOM (common with framework re-renders) and floods the console on shift-heavy pages. This variant guards both, only reports the dominant text shifts, and skips nodes rendered inside a shadow root where source.node may not be directly styleable from the top-level document.
hardened observer with null-node guards and a score floor
const SCORE_FLOOR = 0.01; // ignore trivial sub-threshold shifts
let observer;
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput || entry.value < SCORE_FLOOR) continue;
for (const source of entry.sources ?? []) {
const node = source.node;
// Node may be detached (re-rendered) — fall back gracefully.
const isText = node && node.nodeType === 1 &&
node.textContent?.trim().length > 0;
if (!isText) continue;
const grew = source.currentRect.height - source.previousRect.height;
if (grew <= 0) continue; // only growth = swap signature
const inShadow = node.getRootNode() !== document;
console.warn(
`Swap shift ${entry.value.toFixed(4)} on <${node.tagName.toLowerCase()}>`,
`+${Math.round(grew)}px`,
getComputedStyle(node).fontFamily,
inShadow ? '(inside shadow root)' : '',
);
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
} catch (err) {
// Firefox / Safari: layout-shift unsupported — fail silently in the field.
console.info('layout-shift observer unavailable in this engine', err.name);
}
The try/catch matters because observe() throws TypeError in engines that do not support the layout-shift entry type (Firefox, Safari). Wrapping it lets the same script run everywhere without uncaught errors. The grew <= 0 filter discards both shrinking and pure-translation nodes, leaving only the boxes that expanded — which is exactly what a swap to a taller web font does. The getRootNode() check flags web-component content: source.node still resolves correctly inside a shadow tree, but getComputedStyle calls issued from the top-level document work fine too since computed style is per-element, not per-tree — the flag is purely informational, reminding you the fix (a CSS custom property or an internal stylesheet) must be applied inside that component, not in the page's global stylesheet.
Common pitfalls
- Blaming the pushed-down node. The element with the biggest
topMovedis usually a victim, not the cause. Sort byheightDeltainstead — the node that grew is the one whose font swapped. - Running without throttling. On fast connections the web font often wins the race before first paint, so no swap shift is recorded and your table is empty. Always throttle to Slow 4G to reproduce.
- Omitting
buffered: true. Early shifts fire before the observer attaches; without buffering your total under-reports and disagrees with DevTools. - Reading the fallback family and stopping.
getComputedStyle().fontFamilyreturns the whole stack. The face that actually rendered after the swap is the first available web font in that list — that is the file to override, not the system fallback. - Forgetting detached nodes. Framework re-renders can null out
source.node. The basic script throws; use the guarded variant in any real app. - Summing scores per family and stopping there. On a multi-font page, a family with many pushed-down victim nodes can out-score the actual cause in an aggregate table. Always confirm causation with
heightDelta, not the summed score. - Ignoring
font-display: optionalpages that still shift. If a font usesoptionalbut you still see a shift, the swap likely happened on a second navigation where the font was cached mid-render, or the shift is coming from a different face entirely (e.g., an icon font with nofont-displayset at all). Re-run with cache disabled to rule out a cached-font false positive.
Verification
Confirm your suspect in DevTools without the script:
- Open Performance, reload-and-record, and click the largest red Layout Shift block in the Experience / Layout Shifts lane. The affected node it names should match the top row of your
console.table. - In the Network panel (filter:
Font), block the suspect.woff2(right-click → "Block request URL") and reload. If the shift vanishes from the recording, you have confirmed the exact font file. - Unblock it. In Elements → Computed, verify the node's
font-familyresolves to that web font. The fix — asize-adjustorascent-overrideon the fallback — belongs to fallback font stack design, and the numeric derivation is covered in Calculating size-adjust for a system-ui Fallback. - If the shift only reproduces on a cold cache, repeat step 2 with Disable cache checked in the Network panel — a font that is CDN-cached after the first visit will not shift on repeat views, which can mask the bug in casual manual testing.
A correct diagnosis means three numbers agree: the script's summed score, the Performance panel's CLS, and the drop you see after blocking the font.
Frequently Asked Questions
Does entry.sources work in Safari or Firefox?
No. The entire layout-shift entry type is Chromium-only, so sources[] exists only in Chrome and Edge (84+). Use the defensive variant's try/catch so the script no-ops cleanly elsewhere, and verify the visual fix manually in other engines using the Network-panel block-and-compare technique from Verification, which works in any browser's DevTools.
Why does heightDelta matter more than the shift score for finding the font?
The score tells you how bad a shift is; heightDelta tells you what changed size. A font swap is fundamentally a box-resize event, so the node with a positive height delta and near-zero top movement is the swapped element — even if a pushed-down sibling has a higher individual score because it moved further down the page.
What if two different fonts both grow at nearly the same time?
This happens when two @font-face blocks resolve close together (e.g., a heading face and a body face both finish downloading within the same frame). You will see two rows with positive heightDelta and near-zero topMoved in the same or adjacent entries. Fix both independently — apply a metric override to each — then re-run the observer to confirm neither remains.
Should I fix the font with the highest score first, or the one that grew the most?
Fix by score contribution first if your goal is the fastest CLS reduction, since that is what Core Web Vitals measures. But check heightDelta on that top-scoring row before writing a fix — if it is a pushed-down victim rather than the actual swap, overriding its metrics will not change anything; you need to trace back up to whichever node above it actually grew.
Can I run this same check on a production page without shipping it permanently?
Yes. Paste the script into the Console on the live page (it does not persist across reloads unless you check "Preserve log" or add it via a Snippet). For a lasting field signal instead of a one-off DevTools session, forward the same entry.sources data into your analytics pipeline as described in Tracking Font CLS with the web-vitals Library.