Core Web Vitals

Hunting Cumulative Layout Shift: Finding What Moves and Why

A method for tracking down layout shift: reproducing it, reading DevTools shift records, the usual suspects from fonts to widgets, and the fix for each.

5 min read
Core Web VitalsCLSPerformance
877 words5 min read

What CLS measures

Cumulative Layout Shift scores how much visible content moves unexpectedly while a page is open. A button that jumps down as an image loads above it, a paragraph that reflows when a font swaps, a banner that pushes the whole page down two seconds after load. Each shift is scored by how much of the viewport moved and how far; the page's CLS is the largest burst of shifts. Good is under 0.1.

It matters because it makes people mis-tap, and because it is a ranking signal. It is also the most fixable of the three Core Web Vitals, because every shift has a specific cause and DevTools will show it to you.

Reproduce it first

Field data (Search Console, CrUX) tells you a page has a CLS problem. It does not tell you which element. Lab tools may not reproduce it either, because shifts often depend on timing, viewport size and whether resources are cached.

To reproduce:

  1. Open the page in a fresh incognito window with the cache disabled.
  2. Set the device toolbar to a phone-sized viewport; most real CLS is on mobile.
  3. Throttle the network to Slow 4G. Slow loading spreads shifts out and makes them visible.
  4. Record a performance profile from before navigation through several seconds after load.
  5. Scroll a little. Some shifts happen only on scroll, from lazy-loaded content.

Read the shift records

In the performance panel, the Layout Shifts track shows each shift as a block. Click one. The summary shows the elements that moved, their positions before and after, and the score. The "Layout shift culprits" section in recent Chrome versions names the likely cause directly: a web font, an unsized image, an injected iframe.

Work through the shifts from largest score down. Fixing the top two usually clears the page.

The usual suspects

Images and video without dimensions. The browser cannot reserve space until the file arrives, so everything below jumps. Fix: width and height attributes on every <img>, or aspect-ratio in CSS. Next.js's Image component and most modern frameworks do this if you give them dimensions; check the ones you wrote by hand and the ones from a CMS rich-text field.

Web fonts swapping. The fallback font renders, the web font arrives with different metrics, every line of text reflows. Fix: preload the primary font, use font-display: optional for fonts where a missed swap is acceptable, and use size-adjust and the related descriptors on the fallback @font-face so the fallback matches the web font's metrics. Next.js's font loader does the metric matching automatically.

Late-injected content above the fold. Cookie banners, announcement bars, promotional strips added by a script after load. Fix: reserve the space in the initial HTML, or position the element as an overlay that does not push content, or render it server-side.

Ads and embeds. An ad slot or a third-party embed with no fixed size. Fix: wrap in a container with a fixed min-height matching the expected size, and accept some blank space over a jump.

Lazy-loaded components. A framework component that renders nothing, then something, when its data or code arrives. Fix: render a skeleton of the same dimensions while loading. In React, the Suspense fallback should be the same size as the content it is replacing.

Dynamically injected iframes. Chat widgets, maps, video players that create their iframe after load. Fix: a sized placeholder, and load the iframe on interaction where possible.

CSS animations on layout properties. Animating height, top or margin shifts the elements around them. Fix: animate transform and opacity instead; they do not affect layout.

Content that changes width when scrollbars appear. A page that is exactly viewport height until content loads, then grows and gains a scrollbar, shifting everything by the scrollbar width. Fix: scrollbar-gutter: stable on the root.

Client-side hydration mismatch. Server HTML and client render differ, so React re-renders and moves things. Fix: make them match; the console warns about mismatches in development.

Shifts that do not count

Shifts within 500 milliseconds of a user interaction are excluded, so an accordion opening on click is fine. Shifts from a user scrolling are not excluded unless within that window; content jumping as a lazy image loads mid-scroll counts. Elements changing size via transform do not count; changing size via layout properties does.

Verifying the fix

Re-record on the same throttled profile. The Layout Shifts track should be empty or show only sub-0.01 blocks. Then wait for field data: CrUX aggregates over 28 days, so Search Console's report improves gradually. A lab measurement of 0 with field data still at 0.2 means a shift you have not reproduced, usually one tied to a specific viewport or a slow third party; return to step one with a different viewport.

Keeping it fixed

CLS regressions come from new content, not new code: an editor uploads an image without dimensions, a marketer adds a banner script. A dimension check on CMS images and a rule that third-party scripts go through a developer are the two policies that hold. It is also one of the checks in a monthly performance review, because the field data changes slowly enough that a regression noticed late is a month of poor scores.

All writingHire me for this