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.sources array landed in Chrome 84; the layout-shift type 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-face stack; if you have not already inventoried it, the metric-matching fix you will apply lives in Fallback Font Stack Design.
Reading a shift's sources[] array Layered diagram showing entry.sources containing a grown node and a pushed node, feeding into a metric-override fix. Reading a shift's sources[] array entry.sources[ ] PerformanceObserver node: h1 (+34px height) grew in place = cause node: footer (+34px top) pushed down = victim size-adjust / ascent-override fix applied to cause
The node that grew in place is the cause; nodes only pushed down are victims.

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 computed font-family of that node. The web font named here (not the fallback) is your suspect.
  • heightDelta — the critical signal. A positive delta with topMoved near 0 means 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 with heightDelta ≈ 0 but a large topMoved was 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.

Per-node shift score vs height delta Bar chart comparing CLS score contributions from four nodes on a page with three web fonts. Per-node shift score vs height delta h1.hero-title 0.121 p.hero-sub 0.048 footer 0.011 nav svg 0.004 CLS score
The hero heading has the highest score AND a positive height delta, confirming it as the cause.

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 topMoved is usually a victim, not the cause. Sort by heightDelta instead — 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().fontFamily returns 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: optional pages that still shift. If a font uses optional but 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 no font-display set at all). Re-run with cache disabled to rule out a cached-font false positive.

Verification

Confirm your suspect in DevTools without the script:

  1. 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.
  2. 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.
  3. Unblock it. In Elements → Computed, verify the node's font-family resolves to that web font. The fix — a size-adjust or ascent-override on the fallback — belongs to fallback font stack design, and the numeric derivation is covered in Calculating size-adjust for a system-ui Fallback.
  4. 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.

Verifying the diagnosis in DevTools Numbered steps: read Performance panel, block the suspect font, confirm computed style, repeat with cache disabled. Verifying the diagnosis in DevTools 1 Record Performance find red shift block 2 Block suspect .woff2 Network panel 3 Reload page shift should vanish 4 Check Computed style confirm font-family 5 Retest cache-disabled rule out cached false positive
Three independent checks must agree before you apply a metric-override fix.

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.

Related