Cumulative Layout Shift is the sum of the largest layout shift impact scores inside the worst session window during a page visit. Per web.dev, a passing score is ≤ 0.1. A score above 0.25 fails. In June 2021, Google replaced the original cumulative-forever calculation with a session-window model: shifts get grouped if they happen within 1 second of each other, and a single window caps at 5 seconds. The page's CLS becomes the largest window's score, not the page total. That windowing rewards fixing the worst burst of shift, not every micro-jitter on the page.

Five patterns cause almost every CLS regression. Each has a definitive fix.

Images without explicit dimensions

An <img> tag with no width or height reserves zero pixels until the binary downloads. When the bytes arrive, the browser reflows the document and pushes everything below the image down. That reflow is a layout shift, and it scales with the size of the image relative to the viewport.

Fix: set both width and height HTML attributes on every <img> and <video>. Modern browsers compute aspect-ratio from those attributes and reserve the box before the file loads. Per web.dev's image guidance, this works for responsive images too — the intrinsic ratio is what matters, not the rendered pixel size.

<img
  src="/hero.webp"
  width="1280"
  height="720"
  alt="Dashboard screenshot"
  style="width: 100%; height: auto;"
/>

For background images or CSS-driven media, declare aspect-ratio on the container:

.media {
  aspect-ratio: 16 / 9;
  width: 100%;
  background: var(--placeholder);
}

Gotcha: srcset does not change the aspect ratio. Every candidate inside a srcset must share the same intrinsic ratio as the width/height attributes, or you reintroduce the shift on viewport resize.

Web fonts without font-display

A custom font that swaps in late re-flows every line it touches. Different fonts have different x-height, cap-height, and advance widths, so the swap rarely lands on the same line breaks the fallback used.

Fix has two parts. First, declare font-display: optional for fonts you treat as decoration, or font-display: swap paired with a preload link for fonts that carry brand identity. Second, tune the fallback with size-adjust, ascent-override, and descent-override so the fallback metrics match the web font before it loads.

<link
  rel="preload"
  href="/fonts/inter-variable.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-variable.woff2") format("woff2-variations");
  font-display: swap;
  font-weight: 100 900;
}

@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: "Inter", "Inter Fallback", system-ui, sans-serif;
}

Gotcha: font-display: optional gives the browser a 100ms window to fetch the font. Miss the window, and the font never swaps for that pageview. Use it for body copy where the swap hurts more than the missing font does.

Ad slots that fill in late

Ad networks inject creative into a slot after the auction resolves. If the slot has no reserved height, the ad pushes content down on arrival. Header bidding makes this worse — the slot waits on a network round-trip before sizing.

Fix: reserve the largest slot dimension as a min-height on the container. The slot will hold its space even when the ad is shorter, and multi-size slots stop shifting between auctions.

<div
  class="ad-slot"
  data-slot="leaderboard"
  style="min-height: 250px; min-width: 300px;"
></div>

Gotcha: sticky ads that resize on scroll create new shifts inside the session window. Lock the slot to a single size for the first viewport, and only allow size changes after a user interaction.

Cookie banners and CMS-injected content

A consent banner that mounts at the top of the document body pushes the hero down on every first visit. Same problem for CMS modules that inject promo bars, A/B test variants, or geo-targeted notices after hydration.

Fix: render the banner with position: fixed so it overlays the document instead of displacing it. Better, render the consent state server-side from the cookie and ship the banner only when consent is missing.

.consent-banner {
  position: fixed;
  inset: auto 0 0 0;
  z-index: 50;
  padding: 1rem;
  background: var(--surface);
  box-shadow: 0 -1px 0 var(--border);
}

Gotcha: position: fixed solves the shift but covers content. Pair it with padding-bottom on the page or a dismissible close action so users can reach the footer.

Async widgets and skeleton-less components

Embeds, comment threads, related-posts widgets, and any client-fetched component that mounts after first paint will shift the surrounding layout when it arrives. React, Vue, and Svelte all do this when a Suspense boundary or await resolves into real content above the fold.

Fix: render a fixed-size skeleton in the same DOM position as the final component. The skeleton must match the eventual height and width, including any margins. For lists, render n skeleton rows where n is the average count from your analytics.

<section class="comments" style="min-height: 480px;">
  <div class="skeleton skeleton-row"></div>
  <div class="skeleton skeleton-row"></div>
  <div class="skeleton skeleton-row"></div>
</section>

Gotcha: content-visibility: auto skips rendering off-screen sections to speed up initial paint, but it can introduce shift when the user scrolls into the skipped region. Pair it with contain-intrinsic-size to declare a placeholder size for the skipped block:

.below-fold {
  content-visibility: auto;
  contain-intrinsic-size: 0 800px;
}

Verify before shipping

Run isitready.dev on your canonical origin to get a CLS reading from a real Lighthouse pass, with the offending elements listed by impact score. The scanner uses the same web-vitals.js library Google ships, so the numbers match what Chrome User Experience Report will collect from real users. Catch the shift in CI, not in the field.