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 <img> had no width/height
└─ sources[0]: <img.hero>  rect 0,0,800×0 → 0,0,800×420
layout-shift  value: 0.061  hadRecentInput: false  — web font swapped, text reflowed
└─ sources[0]: <h1>  rect 0,420,800×48 → 0,420,800×56
session window total: 0.203  — over 0.1 budget
Anatomy of a layout-shift entry A layout-shift entry exposes value, hadRecentInput, and a sources array holding the previous and current rectangles of each moved node. layout-shift entry value = impact × distance summed into CLS hadRecentInput: boolean true = skip this shift sources[]: moved nodes previousRect currentRect

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.

Input-gate decision for each shift Each incoming layout-shift entry is routed by hadRecentInput: true entries are excluded, false entries add to the session window. incoming entry hadRecentInput ? excluded from CLS user gesture, expected sessionValue += value counts toward the window true false
// ✅ 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.

Shifts grouped into session windows on a load timeline Three early shifts form one window summing to 0.203; a gap over one second starts a second window summing to 0.05, and CLS is the worst window. load time session window 1 Σ 0.203 → CLS window 2 Σ 0.05 gap > 1s: new window
// ✅ 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.

Un-sized image reflow versus a reserved box Without width and height the late image shoves the heading down by 0.142; with a reserved box the heading stays put and the shift is zero. 1. before img bytes img 0-height h1 + copy 2. img paints late hero img h1 + copy shift 0.142 reserved box (fix) width/height h1 + copy shift 0
<!-- ❌ 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

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

CLS is one of three field-measured vitals; the responsiveness half of the same picture is covered in Measuring INP with the Event Timing API, and both share the observer plumbing described in PerformanceObserver API Patterns.

Reading the Sources Array

The Layout Instability API’s real power is the sources array on each layout-shift entry: it lists the specific elements that moved, with their previous and current bounding rects. This turns a bare CLS score into a list of culprits — an image without reserved dimensions, an ad slot that expanded, a banner injected above the fold, or text reflowing after a late font swap. Rather than guess which element shifted, you read it directly and go fix that element’s reserved space. The hadRecentInput flag on each entry lets you exclude shifts that followed a user interaction, which are expected and not counted toward CLS, so your reporting focuses only on the unexpected shifts that actually harm the score.

Frequently Asked Questions

Why do I need buffered: true when observing layout-shift entries?

The worst shifts happen in the first few hundred milliseconds of load, usually before your measurement script parses and runs. Passing buffered: true to observe() replays entries the browser buffered before the observer existed, so early un-sized-image and font-swap shifts are not silently dropped from your session total.

Why is CLS not just the sum of all layout-shift values?

The browser groups shifts into session windows — shifts within 1 second of each other, capped at 5 seconds — and reports only the single worst window. Summing every shift would penalise long-lived pages (infinite scroll, SPAs) for shifts spread across a whole session, so the windowed maximum is used instead.

What does a previousRect with zero height tell me?

It means the source node occupied no vertical space before the shift and then jumped to a real height. That signature is a classic un-sized media element: the image or embed reserved no box during layout, so everything below it reflowed when the asset finally arrived. Add width/height or aspect-ratio to reserve the box up front.

Why are shifts with hadRecentInput excluded from CLS?

A shift within 500ms of a tap, click, or keypress is treated as an expected consequence of the user’s own action — opening an accordion, submitting a form. Counting those would inflate CLS with movement the user caused and expected, so the metric excludes any entry whose hadRecentInput is true.

Can I capture layout-shift sources in real user monitoring?

Yes. The sources array with previousRect and currentRect is available in the field, but the live node reference may be detached by the time you serialise it. Capture a stable selector or element id inside the observer callback synchronously, then ship that string with the shift value to your RUM endpoint.