Debugging CLS with the Layout Instability API

Cumulative Layout Shift is the Core Web Vital that most often passes in Lighthouse and fails in the field. A static page loaded once in a lab, with no scrolling, no consent banner for the tester's region, no slow third-party embed and no font that arrives late on a real phone, simply does not reproduce what readers experience. Fixing field CLS means finding out which element moved on real readers' screens, why, and when — and the browser tells you exactly that through the Layout Instability API, if you ask.

This guide uses the API directly with PerformanceObserver, then through the web-vitals library's attribution build in production, and in Chrome DevTools, to find the shifts behind a field CLS of 0.19 on a static documentation site and fix them. It is part of Cumulative Layout Shift Fixes for Static Sites, and builds on Measuring CLS in the Field with web-vitals.js.

Prerequisites

  • A static site with field CLS data (CrUX, Search Console, or your own RUM) showing a problem.
  • Chromium-based browsers for testing; the Layout Instability API is supported in Chromium only, which still covers most field data.
  • The ability to ship a small script to production, and somewhere to send its data.

How a Layout Shift Entry Looks

Each time visible elements move between frames without recent user input, the browser emits a layout-shift entry:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue;              // shifts within 500 ms of input don't count
    console.log(entry.value.toFixed(4), entry.startTime.toFixed(0),
      entry.sources.map((s) => ({ node: s.node, from: s.previousRect, to: s.currentRect })));
  }
}).observe({ type: 'layout-shift', buffered: true });

value combines the impact fraction (how much of the viewport the moving elements covered) and the distance fraction (how far they moved). sources lists up to five DOM nodes that moved, with their rectangles before and after. buffered: true returns entries that happened before the observer was created, so the snippet works even when pasted into the console after load.

Step 1: Reproduce Locally With the Console Snippet

Pasting the observer into DevTools on the affected page, with network throttled to "Fast 4G", CPU slowed 4×, a narrow mobile viewport and the cache disabled, showed three shifts on load:

TimeValueMoved nodeCause
820 ms0.061main > articleweb font swap changed heading line heights
1,420 ms0.094.content, footer"What's new" banner inserted above content by script
2,900 ms0.018.feedback-widgetfeedback widget mounted and expanded

Their session window sum was 0.173, close to the field p75 of 0.19. None of these shifts appeared in Lighthouse's default run, because the banner script and the widget loaded after Lighthouse considered the page settled, and its test font was already cached from a previous run.

Timeline of layout shifts on a documentation page A timeline of one throttled page load. At 820 milliseconds a web font swap shifted the article by 0.061. At 1,420 milliseconds a banner inserted above the content shifted content and footer by 0.094. At 2,900 milliseconds the feedback widget expanded and shifted by 0.018. The three shifts fall in one session window totalling 0.173. Three shifts on one page load Font swap Banner inserted Widget expands 820 ms · 0.061 heading line heights changed 1,420 ms · 0.094 content pushed down 2,900 ms · 0.018 footer area moves
Captured with a PerformanceObserver on Fast 4G with 4x CPU slowdown and a mobile viewport.

Step 2: Collect Attribution From Real Readers

Local reproduction finds shifts that happen for you. Field attribution finds the ones that happen for readers. The web-vitals library's attribution build reports, for the CLS value, the largest shift's target element, time and load state:

import { onCLS } from 'web-vitals/attribution';
onCLS(({ value, attribution }) => {
  navigator.sendBeacon('/rum', JSON.stringify({
    metric: 'CLS', value, page: location.pathname,
    target: attribution.largestShiftTarget,          // CSS selector of the element
    time: Math.round(attribution.largestShiftTime),
    loadState: attribution.loadState                 // e.g. 'dom-content-loaded', 'complete'
  }));
}, { reportAllChanges: false });

Grouping a week of these by target across all pages showed the field picture. The banner and the font swap were the top two, as expected, but third was .consent-bar — the consent banner, shown only to readers in the EU, which the team in the US had never seen shift anything. Fourth was img.screenshot on older pages written before image dimensions were added automatically.

Share of poor CLS page views by largest shift target Horizontal bars of the share of page views with CLS above 0.1, grouped by the largest shift target. What's new banner 38 percent. Headings during font swap 24 percent. Consent bar 19 percent. Screenshots without dimensions 11 percent. Feedback widget 5 percent. Other 3 percent. Largest-shift targets across one week of field data What's new banner 38% Headings (font swap) 24% Consent bar (EU only) 19% Screenshots without size 11% Feedback widget 5% Other 3%
From web-vitals attribution beacons on about 410,000 page views; only views with CLS over 0.1 counted.

Step 3: Confirm in DevTools

The Performance panel shows each shift in the Experience track (Layout Shifts track in recent versions). Clicking one highlights the moved elements on the screenshot and lists them with before and after positions. Recording a throttled reload with screenshots enabled makes the cause obvious: the frame before the banner shift shows the content at its original position; the next frame shows the banner and the content pushed down. The "Layout Shift Regions" rendering option (Rendering drawer) flashes moving areas blue during a live session, which helps with shifts that happen during scrolling.

A useful distinction: sources names elements that moved, not the element that caused the move. The banner itself did not "shift" — it appeared; the content below it shifted. Reading the sources, then looking at what changed above them, is the pattern for almost every shift.

Step 4: Fix Each Cause

With the causes identified, fixes were straightforward and mostly covered in sibling guides:

  • Banner: render it in the static HTML at build time from a data file, instead of inserting it with JavaScript after load. It now occupies its space from the first frame.
  • Font swap: metric-matched fallback fonts with size-adjust and ascent-override, as in Metric-Matched Fallback Fonts with size-adjust.
  • Consent bar: positioned fixed at the bottom as an overlay instead of pushing content, as in Fixing CLS from Cookie Banners.
  • Screenshots: a Markdown image render hook now writes width and height from the image file for every image, including old content.
  • Feedback widget: a placeholder with a fixed min-height equal to the widget's collapsed size.
Debugging workflow for layout shifts Field CLS shows a problem on a template. web-vitals attribution groups poor views by largest shift target. A console PerformanceObserver reproduces the shift locally under throttling. DevTools confirms the frames before and after and the element that caused the movement. The cause is fixed at build time where possible, and field CLS is watched for a week. From field value to fixed element Field CLS p75 0.19, docs template Attribution largest shift target Reproduce observer + throttling DevTools frames and cause Fix and watch build-time fix, 7 days
The attribution step is what surfaced the EU-only consent bar shift that no one on the team could reproduce.

Shifts After Load and During Scrolling

Field CLS includes shifts for the page's whole lifetime. Two patterns only showed up in the field on this site: an image lazy-loaded without dimensions that shifted content when readers scrolled to it (excluded from lab runs that do not scroll), and a "back to top" button that changed the height of the footer when it appeared. Shifts during scrolling count unless they are the result of recent input — scrolling is not considered input for this purpose — so they must be fixed like any other. The observer snippet can log shifts during a manual scroll-through of a long page; adding entry.startTime and window.scrollY to the log points at them quickly.

Measured Impact

Field CLS at p75 on the documentation template, from RUM, one week before and two weeks after the fixes:

MeasureBeforeAfter
CLS p75, mobile0.190.03
CLS p75, desktop0.110.02
Page views with CLS > 0.131%4%
Search Console "CLS issue" URL groups2 (docs, blog)0
Lighthouse CLS, docs page0.00 (before and after)0.00

The last row is the reason this guide exists: Lighthouse reported zero throughout.

Pitfalls & Rollback

  • Trusting lab CLS alone. Late insertions, regional banners and scroll-time shifts rarely appear in Lighthouse.
  • Fixing the moved element instead of the cause. Sources list what moved; look above them for what changed.
  • Ignoring hadRecentInput. Shifts right after a click or keypress are excluded from CLS; do not chase them.
  • Heavy RUM scripts. Load the web-vitals attribution build with low priority; it is small but should not compete with the page.
  • Rollback: the diagnostics change nothing on the page; each fix is a separate commit that can be reverted individually.

Conclusion

The Layout Instability API tells you which elements moved, when and by how much, on real readers' screens. Collect attribution in the field, reproduce the top shifts locally with a throttled observer, confirm the cause in DevTools, and fix it at build time wherever possible. On this site it turned a CLS of 0.19 that Lighthouse insisted was zero into 0.03, and found a regional banner shift nobody on the team had ever seen.

FAQ

What does the Layout Instability API report?

It emits a layout-shift performance entry each time visible elements move without recent user input, with a value for how much of the viewport moved and how far, a flag for recent input, and a sources list naming up to five DOM nodes that shifted with their previous and current rectangles.

Why do my lab CLS and field CLS differ?

Lab tools such as Lighthouse measure a single page load without scrolling or interaction, while field CLS covers the whole page lifetime, including shifts after scrolling, late-loading ads and embeds, and bfcache restores. Shifts that only happen for some readers, such as a consent banner in one region, also appear only in the field.

Which element should I fix when a shift has several sources?

Usually the one that caused the movement rather than the ones that moved. Sources lists the nodes that shifted; the cause is often an element above them that changed size, such as an image without dimensions or a banner inserted at the top.

How are shifts grouped into the CLS score?

CLS is the largest session window of shifts, where a window groups shifts less than one second apart and lasts at most five seconds. A page with several small scattered shifts can therefore score lower than a page with one burst of shifts.

Filed underFixes