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:
| Time | Value | Moved node | Cause |
|---|---|---|---|
| 820 ms | 0.061 | main > article | web font swap changed heading line heights |
| 1,420 ms | 0.094 | .content, footer | "What's new" banner inserted above content by script |
| 2,900 ms | 0.018 | .feedback-widget | feedback 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.
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.
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-adjustandascent-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
widthandheightfrom the image file for every image, including old content. - Feedback widget: a placeholder with a fixed
min-heightequal to the widget's collapsed size.
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:
| Measure | Before | After |
|---|---|---|
| CLS p75, mobile | 0.19 | 0.03 |
| CLS p75, desktop | 0.11 | 0.02 |
| Page views with CLS > 0.1 | 31% | 4% |
| Search Console "CLS issue" URL groups | 2 (docs, blog) | 0 |
| Lighthouse CLS, docs page | 0.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.
Related
- Parent: Cumulative Layout Shift Fixes for Static Sites — the main causes of CLS.
- Measuring CLS in the Field with web-vitals.js — field collection basics.
- CLS from Client-Side Rendered Components — shifts from islands and widgets.
- Reserving Space for Images and Embeds to Stop Layout Shift — the image fix.
- Comparing Lab and Field Data with CrUX — why lab and field disagree.
Filed underFixes