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-shiftentry hashadRecentInput: falseinvestigated viasources - No
sourcesentry shows apreviousRect - 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 |