What this usually means
CLS is caused by elements that change size or position after the user has already started viewing the page. The root cause is almost always missing explicit dimensions on images, videos, or embeds, or late-loading content (ads, fonts, third-party widgets) that inject space after paint. But the hidden complexity is that CLS can also come from dynamically injected CSS, animations, or even browser extensions. The real challenge is isolating the shift to a specific frame and resource.
The first ten minutes — establish facts before touching code.
- 1Run `chrome://tracing` or use the Performance panel: record page load and look for red 'Layout Shift' entries in the summary.
- 2Enable the 'Layout Shift Regions' overlay in Chrome DevTools Rendering tab to paint shifted regions in red.
- 3Check CrUX data for your pages: use `PageSpeed Insights` or `BigQuery` with the `chrome-ux-report` dataset to see if CLS is a field issue.
- 4Set up a `PerformanceObserver` for `layout-shift` in a RUM script: `new PerformanceObserver((list) => { ... }).observe({type: 'layout-shift', buffered: true})`.
- 5Test on a throttled connection (Slow 3G) to reproduce shifts caused by late-loading images or ads.
- 6Check if the CLS is caused by a single large shift or many small ones: look at the `value` of each shift in the observer (sum of `currentRect` area changes).
The specific files, logs, configs, and dashboards that usually own this bug.
- searchChrome DevTools Performance record – filter by 'Layout Shift' and inspect the 'Source' stack trace.
- searchNetwork tab – identify late-loading resources that inject dimensions after paint (e.g., images without width/height).
- searchWebPageTest filmstrip view – watch the visual progression and spot the frame where shift occurs.
- searchLighthouse report – the 'Avoid large layout shifts' audit lists specific elements and their contribution.
- searchRUM dashboard (e.g., Datadog, New Relic) – filter by CLS score and look at user agent, connection type, and viewport size.
- search`performance.measureUserAgentSpecificMemory()` – not directly for CLS but can help isolate if shifts correlate with memory pressure.
Practical causes, not theory. These are the things you will actually find.
- warningImages without explicit `width` and `height` attributes – the classic cause.
- warningWeb fonts causing FOIT/FOUT – if the fallback font has different metrics, text reflows.
- warningAds or third-party embeds that dynamically resize after loading.
- warningCSS animations or transitions that trigger layout changes after paint.
- warningLazy-loaded content (images, iframes) that don't reserve space via `aspect-ratio` or a placeholder.
- warningServer-side injected content that inserts elements near the top of the page after the initial HTML is parsed.
- warningBrowser extensions or user styles that modify page layout (hard to reproduce but real).
Concrete fix directions. Pick the one that matches your root cause.
- buildAdd explicit `width` and `height` attributes to all images and video elements, or use `aspect-ratio` CSS.
- buildReserve space for late-loading content: use a placeholder with min-height or a CSS aspect-ratio box.
- buildUse `font-display: optional` or `swap` to prevent invisible text from causing reflow.
- buildLoad critical CSS inline and defer non-critical CSS to avoid style recalculations that shift layout.
- buildFor ads: set a fixed container size and use `overflow: hidden` to prevent ad iframes from expanding.
- buildAvoid inserting DOM elements above existing content after the first paint – batch mutations or use `content-visibility: auto` with proper sizing.
A fix you cannot prove is a guess. Close the loop.
- verifiedRun Lighthouse in incognito mode (5 runs) and confirm CLS is < 0.1.
- verifiedUse the PerformanceObserver to capture layout shifts on a staging page with the fix; sum of shift scores should be near zero.
- verifiedDeploy the fix to a canary and monitor CrUX data for the page path over a week.
- verifiedTest on real devices with different viewports and connection speeds (use WebPageTest repeat view).
- verifiedCheck the 'Layout Shift' entries in DevTools Performance to ensure no red entries appear after first paint.
- verifiedRun a synthetic test with Puppeteer: `page.evaluate(() => { return new Promise(resolve => { new PerformanceObserver(list => { resolve(list.getEntries()); }).observe({type: 'layout-shift', buffered: true}); }); })` and assert the sum < 0.1.
Things that make this bug worse or harder to find.
- warningFixing CLS by adding `height: auto` on images without a container – still causes shift if container isn't sized.
- warningUsing `width: 100%` with no height – the image will still have zero height until loaded.
- warningRelying solely on Lighthouse lab data – field data is what matters for Core Web Vitals.
- warningAdding `min-height` to containers without accounting for content variation – can cause overflow or scrollbar shifts.
- warningIgnoring CLS from dynamic content like carousels or tabs that change size on interaction.
- warningPatching CLS with JavaScript that forces layout after load – can cause a second shift and hurts performance.
The Case of the Shifting Hero Image
Timeline
- 09:15Alert: CrUX data shows CLS for `/product/xyz` jumped from 0.05 to 0.45 over 24 hours.
- 09:20Checked Lighthouse locally: CLS 0.02. No shift. Reran with 'Slow 3G' throttle: CLS 0.55.
- 09:25Opened DevTools Performance record with 3G throttle. Saw a large layout shift at 2.3s from a hero image.
- 09:30Inspected the shift source: the hero image had `width: 100%` but no `height` attribute, and the parent container had `height: auto`.
- 09:35Checked the image component: it was a Next.js `Image` with `layout='fill'` but the parent had no explicit height.
- 09:40Deployed a fix: added `aspect-ratio: 16/9` to the parent container and `sizes` prop to the Image component.
- 09:45Reran Lighthouse: CLS 0.01 on 3G. Monitored CrUX for next 24 hours: CLS dropped to 0.06.
- 09:50Post-mortem: a developer had removed the height prop because the image was 'responsive', but the placeholder wasn't sized.
The alert came in during standup: our flagship product page had a CLS of 0.45 in the field. I was skeptical because Lighthouse on my fast connection showed 0.02. That's the first trap: lab data doesn't reflect real users. I immediately switched to a throttled connection and reproduced the shift.
In the Performance panel, I saw a single massive layout shift at 2.3 seconds. The 'Source' pointed to a `<div>` that wrapped a hero image. The image had `width: 100%` but no `height` attribute. The parent container had `height: auto`. When the image loaded, it expanded from zero height to full height, pushing everything below it down.
I traced it to a Next.js `Image` component with `layout='fill'` – a common pattern that's supposed to handle sizing. But the parent container didn't have an explicit height, so the browser didn't reserve space. I added `aspect-ratio: 16/9` to the parent and ensured the image had `sizes` prop. CLS dropped to 0.01. The lesson: never trust a component to handle sizing – always explicitly define dimensions on the container.
Root cause
Hero image had no explicit height or aspect-ratio on its parent container, causing a 0->full-height expansion on load.
The fix
Added `aspect-ratio: 16/9` to the parent container and provided `sizes` prop to the Next.js Image component.
The lesson
Always define dimensions (width/height or aspect-ratio) on containers of any media element, regardless of framework. Lab data lies; always test with throttled network.
The CLS score is the sum of all unexpected layout shifts during the page's lifespan. Each shift is calculated as `impact fraction * distance fraction`. The impact fraction is the union of the element's previous and current visible area, divided by viewport area. The distance fraction is the distance the element moved divided by viewport max dimension.
A shift is considered 'unexpected' if it occurs without recent user input (e.g., scroll, click). This means shifts triggered by animations or transitions that start after user interaction are excluded. But many shifts happen before interaction, so they count.
In practice, a single shift of 0.05 is okay, but 10 shifts of 0.01 each sum to 0.1 (the threshold). So even small shifts matter if they accumulate. The PerformanceObserver provides `hadRecentInput` flag to filter unexpected shifts.
To capture layout shifts in real user monitoring, use: `new PerformanceObserver((list) => { const cls = list.getEntries().reduce((sum, entry) => sum + entry.value, 0); console.log('CLS:', cls); }).observe({type: 'layout-shift', buffered: true});`.
The `buffered: true` flag gives you previous entries that happened before the observer was registered. This is critical for early shifts. Each entry has `source` array with `node` and `currentRect`, `previousRect`.
You can also get the cumulative CLS via `performance.getEntriesByType('layout-shift')`. But note that the API only reports shifts that occurred during the page's lifetime; if the page is still open, it's a snapshot.
Ads and embeds are notorious for causing CLS because they often load after the page and inject dynamic content. The fix is to reserve space: set a fixed `width` and `height` on the container, or use `aspect-ratio` with `overflow: hidden`.
However, many ad scripts override container styles. To detect, use Chrome DevTools 'Break on subtree modifications' on the container element. Then step through the script to see when it changes size.
For out-of-page ads (e.g., popunders), they can cause CLS by inserting elements at the top of the page. Block them with `Content-Security-Policy` if possible.
Frequently asked questions
Why does CLS vary so much between lab and field?
Lab tools like Lighthouse simulate a fast connection and consistent viewport. Field data (CrUX) reflects real users with varied devices, network conditions, and browser extensions. CLS is highly sensitive to load timing – a 100ms delay in an ad load can turn a 0.02 shift into 0.5. Always test with throttled network and monitor field data.
Can animations cause CLS?
Yes, but only if they are triggered without user input. CSS animations that start on page load are considered unexpected. However, if the animation is triggered by a user action (click, scroll), it's excluded. To avoid CLS from animations, use `transform` and `opacity` properties (which don't trigger layout) and ensure animations don't change element size.
How do I fix CLS from a carousel?
Carousels often shift because slides have different heights. Set a fixed height on the carousel container, or use `aspect-ratio` if the height is proportional to width. For dynamic content, calculate the max height and set it when the carousel initializes. Alternatively, use `content-visibility: auto` on slides to defer layout.