Almost every hand-rolled Cumulative Layout Shift implementation is wrong in the same way: it adds up every layout-shift entry it receives. The specification does not, and the difference is large enough to change whether a page passes.

Problem / Scenario Context

A team instruments CLS, reports it to their own dashboard, and finds their number is roughly three times what Chrome's field data says for the same pages. Nothing is obviously broken — the entries are real, the values are real, the arithmetic is a correct sum.

The sum is the problem. CLS is defined as the largest session window, not the total. A page that shifts a little every time the reader scrolls into a new section accumulates a steady trickle of small shifts that the specification deliberately does not add together, because a page that is stable for ten seconds and then shifts slightly is not three times worse than one that shifts once.

The second common error is the opposite: filtering out too much. Shifts within 500 ms of a user interaction are excluded because the reader caused them — but a page that fires synthetic input events during startup can suppress genuine load shifts and report a suspiciously perfect zero.

Mechanics Explanation

Each layout-shift entry carries three things worth reading: a value (the impact fraction times the distance fraction), a hadRecentInput boolean, and a sources array naming the nodes that moved with their previous and current rectangles.

The score is computed by grouping entries into session windows. A window continues while each new entry is within 1000 ms of the previous entry and within 5000 ms of the window's first entry. Anything outside those bounds starts a fresh window. The reported CLS is the largest window's total.

That definition is what makes the metric describe an episode of instability rather than a lifetime sum, and it is why a long-lived single-page application does not automatically fail.

Session Windows, Not a Running TotalA timeline of seven layout shift entries. The first four fall within a second of each other and form one window totalling 0.18. A gap of several seconds starts a second window of two entries totalling 0.06, and a third window holds a single entry of 0.03. The naive sum is 0.27 while the reported metric is the largest window, 0.18.window 1 = 0.18window 2 = 0.06window 3 = 0.03gaps over 1s end a windownaive sum: 0.27 — fails the 0.25 thresholdlargest window: 0.18 — the actual metric, and a pass

Comparison Table: Sum vs. Windowed

Page behaviour Naive sum Windowed score Which is right
One bad shift at load same same either
Many tiny shifts over 5 minutes large and growing small windowed
Two bad episodes far apart added together the worse one windowed
Long single-page session grows forever bounded windowed
Shift 200 ms after a click counted excluded windowed

Minimal Reproducible Example

TypeScript
// The common wrong implementation
let cls = 0;
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) cls += (e as any).value;   // no windows, no input filter
}).observe({ type: 'layout-shift', buffered: true });

Production-Safe Solution

TypeScript
interface ShiftEntry extends PerformanceEntry {
  value: number;
  hadRecentInput: boolean;
  sources: Array<{ node?: Node; previousRect: DOMRectReadOnly; currentRect: DOMRectReadOnly }>;
}

let windowValue = 0;
let windowEntries: ShiftEntry[] = [];
let cls = 0;
const offenders = new Map<string, number>();

new PerformanceObserver((list) => {
  for (const raw of list.getEntries()) {
    const entry = raw as ShiftEntry;
    if (entry.hadRecentInput) continue;                 // the reader caused it

    const first = windowEntries[0];
    const last = windowEntries[windowEntries.length - 1];
    const inWindow = windowEntries.length > 0
      && entry.startTime - last.startTime < 1000
      && entry.startTime - first.startTime < 5000;

    if (inWindow) { windowValue += entry.value; windowEntries.push(entry); }
    else { windowValue = entry.value; windowEntries = [entry]; }

    if (windowValue > cls) cls = windowValue;

    // attribution: which node moved, and by how much in total
    for (const src of entry.sources ?? []) {
      const el = src.node instanceof Element ? src.node : null;
      if (!el) continue;
      const key = describe(el);
      offenders.set(key, (offenders.get(key) ?? 0) + entry.value);
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

function describe(el: Element): string {
  const id = el.id ? `#${el.id}` : '';
  const cls0 = typeof el.className === 'string' && el.className.trim()
    ? `.${el.className.trim().split(/\s+/)[0]}` : '';
  return `${el.tagName.toLowerCase()}${id}${cls0}`;
}

addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden') return;
  const worst = [...offenders.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
  navigator.sendBeacon('/vitals', JSON.stringify({
    cls: Number(cls.toFixed(4)),
    worstShifters: worst.map(([sel, v]) => ({ sel, v: Number(v.toFixed(4)) })),
    path: location.pathname,
  }));
}, { once: true });

The offenders map is what makes the number actionable. A CLS of 0.18 tells you there is a problem; img.hero-media accounting for 0.15 of it tells you which line to change — almost always a missing width/height pair or a missing aspect-ratio reservation.

Where Shift Comes From, and What Removes ItThree recurring causes with their remedies. Media without reserved dimensions is fixed by width and height attributes or an aspect ratio. A web font swapping metrics is fixed by size-adjust descriptors or a matched fallback. Content injected above the current scroll position, such as a banner or an ad, is fixed by reserving its slot before it arrives.SourceFixMedia with no reserved boxthe image arrives and pushes everything downwidth + height attributes, or aspect-ratiocosts nothing and removes the shift entirelyA web font swapping inline counts change and every block below movessize-adjust / ascent-override on the fallbackor preload the font so the swap happens earlyContent injected above the folda banner or ad slot that arrives latereserve the slot's height before it loadsan empty box of the right size shifts nothing

How a Shift Score Is ComposedThe layout shift value is the product of two fractions. The impact fraction is the share of the viewport occupied by the union of the element's positions before and after the move. The distance fraction is how far it travelled as a share of the viewport's larger dimension. The example shows a banner covering half the viewport moving one tenth of its height, producing a score of 0.05.value = impact fraction × distance fractionunion of before + afterimpact = 0.50×beforeaftermoved 1/10 of the viewportdistance = 0.10=0.05

Verification Steps

  • Compare against Chrome's own number in the Performance panel's Web Vitals lane on the same load; a large gap means the windowing is wrong.
  • Assert the window logic directly with a unit test that feeds synthetic entries at controlled timestamps and checks the resulting maximum.
  • Confirm the input filter is not over-firing by logging how many entries were skipped for hadRecentInput; a page reporting zero shifts and skipping dozens is suppressing real ones.
  • Verify attribution by outlining the reported worst offender during a manual load: document.querySelector(sel).style.outline = '2px solid red'.

Common Mistakes to Avoid

  • Summing every entry. Produces a number that only grows and does not match any published threshold.
  • Reading entry.element. That field does not exist on a layout-shift entry; the nodes are in sources.
  • Ignoring hadRecentInput. Reader-initiated reflows — opening an accordion, expanding a menu — are not defects and are excluded by definition.
  • Assuming a route change resets it. CLS is per-document. A soft navigation that shifts content adds to the same score, which is one more reason to be careful with observer-driven layout writes.

FAQ

Why is my CLS higher than the one Chrome reports?

Almost certainly because you are summing every entry rather than taking the largest session window. The specification groups entries into windows that end after a one-second gap or a five-second span, and reports the worst window — a definition that deliberately does not punish a page for being long-lived.

What does hadRecentInput actually exclude?

Shifts that occurred within roughly half a second of a user interaction, on the basis that the reader caused them. Opening a menu or expanding an accordion moves content legitimately. Excluding these is required for the number to mean anything, but a page that dispatches synthetic input events early can suppress genuine load shifts by accident.

How do I find out which element moved?

Read the sources array on the entry. Each source has a node reference plus its previous and current rectangles, so you can both name the element and see how far it travelled. There is no element field on a layout-shift entry, which is a common source of undefined.

Does CLS reset when a single-page app changes route?

No. The metric is scoped to the document, so soft navigations accumulate into the same score. Content that shifts during a route transition counts, which makes reserved space just as important on client-rendered views as on the initial load.


↑ Back to PerformanceObserver & Rendering Metrics