What this usually means
CLS is caused by elements that change size or position after the user has already started interacting with the page. The root cause is almost always a missing explicit width/height on images, embeds, or ads, or the use of web fonts that cause a flash of invisible text (FOIT) or flash of unstyled text (FOUT). But there are trickier causes: injected content (like banners or third-party widgets) that push existing content down, or animations/transitions that apply width/height changes. The browser's Layout Instability API tracks every unexpected movement, and debugging means identifying the specific element that shifted and why.
The first ten minutes — establish facts before touching code.
- 1Open Chrome DevTools > Performance tab, check 'Layout Shift' events in the summary
- 2Run `lighthouse --view` on the page and inspect the 'CLS' diagnostic under 'Performance'
- 3In DevTools, go to Rendering > 'Layout Shift Regions' to see blue rectangles for each shift
- 4Record a performance trace, filter by 'Layout Shift', and click each shift to see the affected nodes
- 5Check CrUX (Chrome User Experience Report) in PageSpeed Insights for real-user CLS scores
The specific files, logs, configs, and dashboards that usually own this bug.
- searchDevTools > Rendering > Layout Shift Regions (live overlay)
- searchDevTools > Performance > Summary > Layout Shift entries
- searchLighthouse report > Performance > 'Avoid large layout shifts'
- searchweb-vitals.js console logs (if instrumented) for CLS value at page unload
- searchNetwork tab: font files (FOIT/FOUT), image loads, third-party scripts
- searchCrUX data via PageSpeed Insights or Google Search Console
Practical causes, not theory. These are the things you will actually find.
- warningImages without explicit width and height attributes
- warningWeb fonts causing FOIT/FOUT (swap or fallback period)
- warningAds or dynamic content injected after page load that push layout
- warningCSS animations or transitions that change element dimensions
- warningIFrame embeds with no fixed size (YouTube, maps, etc.)
- warningLate-loading custom fonts that swap to a different size
Concrete fix directions. Pick the one that matches your root cause.
- buildAdd explicit `width` and `height` attributes to all `<img>` and `<video>` elements
- buildUse `font-display: optional` or `swap` with matching font metrics via `size-adjust`
- buildReserve space for ads by wrapping them in a fixed-size container (min-height/min-width)
- buildFor embeds, set aspect-ratio: 16/9 via CSS and container intrinsic size
- buildAvoid inserting dynamic content above the fold; use a placeholder with known size
- buildUse `content-visibility: auto` with contain-intrinsic-size for off-screen sections
A fix you cannot prove is a guess. Close the loop.
- verifiedRun Lighthouse three times and confirm CLS is below 0.1 consistently
- verifiedUse DevTools > Rendering > 'Layout Shift Regions' and refresh to see no blue rectangles
- verifiedMonitor CrUX over a week to see real-user CLS drop
- verifiedRecord a performance trace and check for zero layout shift entries
- verifiedTest on mobile throttling (Slow 3G) to catch late-loading shifts
- verifiedAdd `performanceObserver` for layout-shift and log entries in console
Things that make this bug worse or harder to find.
- warningRelying solely on Lighthouse lab data—real-user CLS can differ wildly
- warningAdding dimensions that don't match the actual aspect ratio (causing scaling distortion)
- warningUsing `font-display: block` which forces FOIT and can increase CLS
- warningSetting width/height in CSS but not in HTML for images (CSS may be overridden)
- warningIgnoring shifts caused by scrollbar appearance (use `overflow: overlay` or always show scrollbar)
- warningAssuming all shifts are from images—check for late-loading custom fonts too
The Ad That Pushed the Hero Image Down
Timeline
- 09:15Alert: CrUX CLS for product pages jumped from 0.05 to 0.42 in 24 hours
- 09:30Checked Lighthouse on desktop — CLS 0.02, fine. Ran on mobile with 3G throttle — CLS 0.38
- 09:45Enabled Layout Shift Regions overlay in DevTools. Refreshed: saw a blue rectangle right below the hero image
- 10:00Recorded performance trace: layout shift at 1.2s, culprit node was an ad container that appeared after main content
- 10:15Discovered ad script loaded async but was injected into the DOM after hero image, pushing it down by 120px
- 10:30Added a fixed-height placeholder div (min-height: 250px) for the ad slot above the fold
- 10:45Re-ran Lighthouse mobile: CLS 0.03. Deployed fix and monitored CrUX
- 14:00CrUX updated: CLS back to 0.06. Incident resolved.
We had a CLS alarm from CrUX that looked like a full-site regression. Our lab tests on desktop showed nothing. I immediately suspected something viewport-dependent or network-dependent. I throttled to Slow 3G in DevTools and reproduced the shift on mobile.
Using Layout Shift Regions, I saw a big blue rectangle right under the hero image. The performance trace showed the shift event at 1.2 seconds, triggered by a DOM insertion of an ad div. The ad script was loading asynchronously but rendering into a slot that had no reserved space. It pushed the hero image down by 120px on mobile.
The fix was simple: I gave the ad container a min-height of 250px (the typical ad size) and set overflow: hidden so that if the ad loaded smaller, it wouldn't collapse. After deploying, CrUX CLS dropped to 0.06. The lesson: always reserve space for dynamic content, even if you don't know the exact dimensions.
Root cause
Ad container inserted into the DOM without reserved space, pushing existing content down on mobile viewports.
The fix
Added a fixed-height placeholder (min-height: 250px) to the ad slot with overflow: hidden to prevent layout shift during ad load.
The lesson
Lab tests without network throttling can miss CLS. Always reserve space for third-party content, especially above the fold.
The browser exposes Layout Instability entries via PerformanceObserver. You can log every shift with: `new PerformanceObserver((list) => { list.getEntries().forEach(entry => console.log(entry) }) ).observe({type: 'layout-shift', buffered: true})`. The entry contains `value` (score), `sources` (array of nodes that moved), and `hadRecentInput` (ignore if true).
For production monitoring, you can send the cumulative score at page unload via `navigator.sendBeacon`. But beware: the CLS value changes as the page loads, so you need to store the latest score and send it on `visibilitychange`. Many RUM tools already do this, but if you roll your own, ensure you handle bfcache.
FOUT (flash of unstyled text) can cause CLS when the fallback font and custom font have different metrics. The fix isn't just `font-display: swap` — that still causes a swap. Use `size-adjust` in `@font-face` to adjust the fallback font's metrics to match the custom font. For example: `@font-face { font-family: 'MyFont'; src: url(...); size-adjust: 90%; }`.
Another approach: preload the font with `<link rel=preload as=font>` and set `font-display: optional` so the font only loads if it's already cached. This avoids any swap but may show fallback font. Best for critical text.
For images, setting `width` and `height` attributes in HTML lets the browser calculate the aspect ratio before the image loads. This works because browsers now compute the intrinsic ratio from those attributes. For embeds (like YouTube iframes), you need to wrap them in a container with `aspect-ratio` CSS and set the iframe to fill it.
Example: `<div style="aspect-ratio: 16/9"><iframe ...></iframe></div>`. The iframe should have `width:100%; height:100%`. Without this, the embed collapses to zero height until loaded, then expands, causing a shift.
When content loads that causes the page to become taller than the viewport, the browser may show a scrollbar, which reduces the viewport width and can reflow content. This is particularly nasty on mobile where the scrollbar appears only on overflow. The fix: set `html { overflow-y: scroll; }` to always show the scrollbar, preventing the width change. Or use `overflow: overlay` on the body (Chrome only) to overlay the scrollbar without taking space.
Frequently asked questions
How do I measure CLS locally without Lighthouse?
Open DevTools > Performance, start recording, reload the page, stop recording. In the 'Experience' section, look for 'Layout Shift' entries. Click one to see the score and affected nodes. You can also enable 'Layout Shift Regions' under Rendering to see blue overlays for each shift.
What is a good CLS score?
Google recommends a CLS of less than 0.1 for a 'good' user experience. Between 0.1 and 0.25 is 'needs improvement', and above 0.25 is 'poor'. However, aim for 0.05 or lower to have a buffer for real-user variability.
Can CSS animations cause CLS?
Yes, if an animation changes the size of an element (e.g., expanding a card). The Layout Instability API considers any movement that is not triggered by user input. Use `transform` and `opacity` for animations, which do not trigger layout. Avoid animating `width`, `height`, `top`, `left`, etc.
Why does CLS show in CrUX but not in Lighthouse?
Lighthouse is a lab tool that simulates a clean load with no network variability. Real users have different cache states, network speeds, and devices. CrUX captures actual user experiences. Third-party scripts, slow fonts, or dynamic ads may load differently per user. Throttle your network in DevTools to better simulate real conditions.
I added width and height to images but CLS is still high. What else?
Check if images are inside a flexbox or grid container that stretches them. The width/height attributes set an aspect ratio, but if CSS overrides the dimensions, the image may still cause shifts. Also, check for late-loading fonts, third-party widgets, or injected content like cookie banners. Use Layout Shift Regions to identify the exact element.