Debugging CLS with the Layout Instability API

Cumulative Layout Shift is measured through the Layout Instability API — the layout-shift entries delivered by PerformanceObserver — which scores each unexpected movement of already-painted content, flags shifts that followed user input with hadRecentInput, and lets you sum the rest into session windows and trace them back to un-sized images and late-loading fonts. This page is part of Core Web Vitals Measurement, within Rendering Performance Metrics and Tooling.

What a layout-shift Entry Contains

Every time the browser moves a visible element from one rendered position to another without a corresponding user interaction, it records a layout-shift entry. The entry’s value is the impact fraction times the distance fraction; hadRecentInput is true if the shift happened within 500ms of a user gesture (those are expected and excluded from CLS); and sources lists the nodes that moved, with their previous and current bounding rectangles.

[layout-shift stream — load with un-sized hero + late font]
layout-shift  value: 0.000  hadRecentInput: true   — user expanded a menu, ignored
layout-shift  value: 0.142  hadRecentInput: false  — hero  had no width/height
└─ sources[0]:   rect 0,0,800×0 → 0,0,800×420
layout-shift  value: 0.061  hadRecentInput: false  — web font swapped, text reflowed
└─ sources[0]: 

rect 0,420,800×48 → 0,420,800×56 session window total: 0.203 — over 0.1 budget

Observing Shifts and Skipping Input

Always filter on hadRecentInput. Shifts that follow a tap or keypress are the user’s own doing and must not count toward CLS — including them produces inflated, unactionable scores.

// ✅ Sum only non-input shifts; capture sources for attribution
let sessionValue = 0
const po = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue // expected shift, excluded from CLS
    sessionValue += entry.value
    for (const src of entry.sources) {
      console.log(src.node, src.previousRect, src.currentRect) // which node jumped
    }
  }
})
po.observe({ type: 'layout-shift', buffered: true })

buffered: true is essential here: the worst shifts happen during the earliest moments of load, before your script runs. Without buffering you miss exactly the entries you need. This is the same observer pattern documented in Core Web Vitals Measurement.

Session Windows

CLS is not the simple sum of every shift. The browser groups shifts into session windows — a window holds shifts that occur within 1 second of each other, capped at 5 seconds total — and the page’s CLS is the value of the single worst window, not the lifetime total. This prevents a long-lived page from accumulating an ever-growing score.

// ✅ Track the maximum session window, not a running total
let cls = 0, win = 0, last = 0, first = 0
const po = new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.hadRecentInput) continue
    // new window if >1s since last shift or >5s since window start
    if (win && (e.startTime - last > 1000 || e.startTime - first > 5000)) win = 0
    if (win === 0) first = e.startTime
    win += e.value
    last = e.startTime
    cls = Math.max(cls, win) // report the worst window
  }
})
po.observe({ type: 'layout-shift', buffered: true })

Tying Shifts Back to Causes

The sources array is the debugging payoff: a shift whose previousRect has a height of 0 that jumps to a real height is a classic un-sized media element — the browser reserved no space, so neighbors reflowed when the asset arrived.

<!-- ❌ No intrinsic size reserved: image arrives late and shoves content down -->
<img src="/hero.jpg" class="hero">

<!-- ✅ width/height (or aspect-ratio) lets the browser reserve the box up front -->
<img src="/hero.jpg" class="hero" width="800" height="420">

With width and height present, the browser computes the aspect ratio and reserves the box during layout, before the bytes load — so nothing reflows when the image paints. The other frequent source is text reflowing when a web font swaps in at a different metric than the fallback; that shift is fixed by sizing and loading fonts carefully, covered in Reducing Layout Shift from Web Fonts. Both causes are layout-phase problems, which is why the structural remedies live across Layout and Paint Optimization.

Verification

  • Every remaining layout-shift entry has hadRecentInput: false investigated via sources
  • No sources entry shows a previousRect
  • Font swap produces no <h1>/<p>
Metric Target How measured
CLS (p75) < 0.1 max session-window sum of non-input layout-shift
Un-sized media shifts 0 sources with 0-height previousRect
Font-swap shifts 0 sources text node rect change on font load