Web Development

Debugging Cumulative Layout Shift With Chrome DevTools: A Real Before/After

A page with a CLS score of 0.31 traced to two specific causes using DevTools' Performance panel and Layout Shift Regions overlay, fixed down to 0.02.

By Aissam Ait Ahmed Web Development 0 comments

A blog post template flagged with a Cumulative Layout Shift score of 0.31 in Search Console — well past Google's "needs improvement" threshold of 0.1 — needed an actual diagnosis, not a guess. "Layout shift" as a concept is intuitive; finding the specific element causing it on a specific real page is a different problem, and Chrome DevTools has purpose-built tools for exactly that which most people never open.

Reproducing the shift locally first

Search Console reports a field score aggregated from real user data, which tells you a problem exists but not where — reproducing it locally with DevTools' Performance panel is the first step, recording a page load with throttling enabled to make shifts that happen quickly on a fast connection more visible and easier to catch on a slower, more representative one.

1. Open DevTools → Performance panel
2. Set network throttling to "Fast 3G" and CPU to "4x slowdown"
   (approximates a real mid-range mobile device on average connection)
3. Click Record, then reload the page
4. Stop recording once the page has fully loaded

The Performance panel's summary at the top of the recording shows a CLS score for that specific run, and — critically — the "Experience" track along the timeline marks a red bar at the exact moment each layout shift occurred, which is the starting point for figuring out what caused it.

Using Layout Shift Regions to see it visually

Rather than only reading a number, DevTools' rendering tab has a "Layout Shift Regions" overlay that highlights, directly on the live page, exactly which elements moved and by how much, in real time as the page loads:

1. Open DevTools → press Escape to open the drawer → "Rendering" tab
2. Check "Layout Shift Regions"
3. Reload the page and watch for blue flashes highlighting
   exactly which elements shift, at the moment they shift

On the flagged blog post page, two distinct blue flashes appeared during load: one around the featured image at the top of the article, and a second, smaller one around an embedded newsletter signup form roughly halfway down the page.

Cause 1: an image with no reserved space

Clicking into the specific layout shift event in the Performance panel's Experience track showed the exact element and the shift's magnitude — a shift of roughly 180 pixels caused by the featured image, which had no width or height attributes and no CSS aspect-ratio reserving its space before the actual image file finished downloading:

<!-- Before: no reserved space, causes a shift once the image loads -->
<img src="/images/featured-post.jpg" alt="Article featured image" class="w-full">
<!-- After: width/height let the browser reserve the correct space
     immediately, before the image file has even started downloading -->
<img src="/images/featured-post.jpg" alt="Article featured image"
     class="w-full" width="1200" height="675">

Adding the image's real intrinsic width and height attributes lets the browser calculate the correct aspect ratio and reserve exactly that much vertical space in the layout before the image file has downloaded at all — everything below the image renders in its final position from the very first paint, rather than jumping down once the image arrives and its real dimensions become known. This alone brought the measured CLS down from 0.31 to roughly 0.09 in the local recording, which was already most of the fix.

Cause 2: a third-party embed injecting itself after initial layout

The remaining shift traced to the newsletter signup embed — a third-party script that injected its own iframe into a placeholder <div> after an asynchronous script finished loading, and that <div> had no explicit height set beforehand, collapsing to zero height until the script injected content and expanded it, pushing everything below it down at that moment.

<!-- Before: div collapses to 0 height until the async script fills it -->
<div id="newsletter-embed"></div>
<script src="https://embed.newsletterprovider.com/widget.js" async></script>
<!-- After: reserved space matches the embed's actual real rendered height -->
<div id="newsletter-embed" style="min-height: 280px;"></div>
<script src="https://embed.newsletterprovider.com/widget.js" async></script>

Setting min-height to match the embed's actual real rendered height — measured directly by inspecting the injected iframe's dimensions once loaded, not guessed — reserved that space from the start, so the page no longer shifted when the script eventually injected its content into an already correctly-sized container. This is a genuinely common blind spot with third-party embeds generally: the space they'll eventually occupy is entirely outside your control in terms of when it loads, but reserving the space in advance is well within your control and eliminates the shift regardless of how slowly the third party's own script happens to load.

The result, measured the same way

Re-running the same Performance panel recording, with the same throttling settings, after both fixes shipped showed a CLS score of 0.02 — comfortably under the 0.1 "good" threshold, down from the original 0.31. The Layout Shift Regions overlay, checked again on the same page, showed no visible flashes at all during load, which was a useful confirmation beyond just the numeric score: a page can technically score under 0.1 while still having a small, easy-to-miss shift somewhere that a purely numeric check wouldn't flag as worth investigating further.

A generalizable checklist from this specific case

  • Every <img> needs explicit width and height attributes (or a CSS aspect-ratio), so the browser can reserve correct space before the file downloads — this is the single most common CLS cause across real sites generally, not just this one.
  • Any container that will eventually hold async-injected content — third-party embeds, lazy-loaded components, content fetched after initial render — needs a reserved minimum height matching its expected final size, set before the async content arrives.
  • DevTools' Layout Shift Regions overlay is worth checking even after a numeric CLS score looks acceptable, since it can surface a small shift a passing aggregate score doesn't necessarily flag as significant.
  • Reproduce with realistic throttling, not a fast local connection, since shift-causing delays that are invisible on a fast connection can be exactly what's affecting real users on average mobile connections in the field data that originally flagged the problem.

If Core Web Vitals work is new to your team more broadly, this pairs directly with what actually moved our Core Web Vitals for the wider picture beyond CLS specifically, and if a slow-loading image was part of what triggered this investigation in the first place, responsive images in 2026 covers getting the right file size loading quickly, which reduces both load time and the risk of exactly this kind of image-caused shift.

A third, easy-to-miss cause worth checking even when the score looks fine

A few weeks after shipping both fixes above, a smaller shift reappeared, traced to a web font swap — the page's body text initially rendered in a fallback system font, then visibly reflowed once the actual custom font finished downloading and swapped in, because the fallback and the custom font had different average character widths, causing paragraphs to reflow and push content below them down slightly.

/* Before: default font-display behavior can cause an invisible-then-swap flash */
@font-face {
    font-family: 'Inter';
    src: url('/fonts/inter-var.woff2') format('woff2');
}

/* After: explicitly pairing a close-matching fallback reduces
   the reflow when the swap happens */
body {
    font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}

@font-face {
    font-family: 'Inter';
    src: url('/fonts/inter-var.woff2') format('woff2');
    font-display: swap;
    size-adjust: 100.2%; /* tuned to match the fallback's average character width */
}

The size-adjust value here isn't a round number picked arbitrarily — it was tuned by comparing rendered text width between the fallback font and the custom font at the same font size and adjusting until paragraphs reflowed as little as possible when the swap occurred, checked directly in the Layout Shift Regions overlay. This is a smaller, subtler shift than either of the first two causes, and it's exactly the kind of thing worth checking specifically once the obvious causes are fixed and the aggregate score already looks acceptable — a 0.02 score is good, and it can still mask a small, specific shift that a closer look with the same tools catches.

Setting up ongoing monitoring instead of relying on periodic manual checks

Fixing what Search Console flagged addresses a problem someone already noticed weeks after real users experienced it, given how field data aggregates over time before surfacing in reports. Wiring up real-user CLS monitoring, using the same web-vitals library Search Console's own field data is built from, catches a regression far sooner than waiting for the next periodic report to reflect it:

import {onCLS} from 'web-vitals';

onCLS((metric) => {
    if (metric.value > 0.1) {
        fetch('/api/vitals', {
            method: 'POST',
            body: JSON.stringify({ name: 'CLS', value: metric.value, page: location.pathname }),
        });
    }
});

Logging only the cases that actually exceed the "good" threshold, rather than every single measurement, keeps the volume of data manageable while still surfacing exactly the pages and moments worth investigating — and because this runs on real visitor devices and real network conditions rather than a single local DevTools recording, it catches shifts specific to device types or connection speeds that a single developer's local testing setup, however carefully throttled, might not happen to reproduce.

The page that started this whole investigation now shows up in that logged data occasionally with small, isolated shift events on specific low-end devices that never appeared in local testing at all, each small enough individually not to warrant the same depth of investigation as the original 0.31 score, but worth having visibility into rather than assuming the fix that brought the aggregate score down to 0.02 locally means the problem is permanently and universally closed across every real device in the field.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Web Development Free Resources Explore Tools