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 blueprint. 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, and an icon font; only one of them may have mismatched metrics. 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.

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.
Reading sources[] to find the swapped node A shift entry's sources array distinguishes the node that grew from a node that was merely pushed down. entry.sources[ ] node: p.body +16px height node: footer +16px top grew → cause moved → victim Override the font that grew: size-adjust / ascent-override
Sort by height delta: the node that grew in place is the swapped font; pushed-down siblings 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.

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 and only reports the dominant text shifts.

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

        console.warn(
          `Swap shift ${entry.value.toFixed(4)} on <${node.tagName.toLowerCase()}>`,
          `+${Math.round(grew)}px`,
          getComputedStyle(node).fontFamily,
        );
      }
    }
  });
  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.

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.

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.

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.

FAQ

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.

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.

Related