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
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
| 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.
Related Guides
- Core Web Vitals Measurement — how CLS fits alongside LCP and INP in field measurement.
- Measuring INP with the Event Timing API — the responsiveness vital, using the same observer pattern.
- PerformanceObserver API Patterns — buffered entries, entry types, and callback timing.
- Reducing Layout Shift from Web Fonts — fix the font-swap half of CLS with metric overrides.
- Layout and Paint Optimization — the layout-phase structural fixes behind stable pages.