Reducing Cumulative Layout Shift (CLS)

This guide is part of Core Web Vitals & Measurement and focuses on one outcome: a repeatable workflow that drives Cumulative Layout Shift below the 0.1 field threshold and keeps it there.

Cumulative Layout Shift quantifies visual instability as a page loads, hydrates, and renders dynamic content. The actionable boundary is unambiguous: a "Good" score is CLS < 0.1 at the 75th percentile of real-user data, "Needs Improvement" runs 0.1–0.25, and anything above 0.25 is "Poor." Lab tools locate the offending nodes; the p75 field number is what actually ships. The work below moves in a fixed order — capture a baseline, isolate the dominant shift source, reserve deterministic space, then assert the budget in CI — because guessing at fixes on a metric this sensitive to async timing wastes deploy cycles.

CLS remediation workflow Four-stage workflow for driving Cumulative Layout Shift below 0.1 with per-stage outputs. CLS workflow (budget 0.1) 1. Baseline RUM p75 2. Isolate shift sources 3. Reserve aspect-ratio 4. Gate CI budget Score = impact fraction x distance fraction, summed per session window. Only the highest-scoring window counts toward the page total. Fix the dominant source first; re-measure before touching the next. Lab pinpoints the node; field p75 confirms the fix held.

Problem Framing: What CLS Actually Penalizes

The browser groups individual shifts into session windows. A window opens on the first unexpected shift and closes after 1 second of inactivity, capped at 5 seconds total. Scores within a window are summed, and only the highest-scoring window contributes to the page's final CLS. This design absorbs transient flicker while still penalizing sustained instability — which is why a single late-arriving ad or font swap can dominate an otherwise stable page.

Each shift is scored as Shift Score = Impact Fraction × Distance Fraction. Impact fraction is the share of the viewport covered by the union of unstable elements' bounding boxes; distance fraction is the largest displacement of any unstable element relative to the viewport's larger dimension. A 20%-of-viewport element moving 10% of viewport height scores 0.20 × 0.10 = 0.02. The practical lesson: large elements moving short distances frequently outscore small elements moving far, so prioritize the heaviest movers, not the most visible ones.

The browser excludes shifts within 500ms of explicit user input via the hadRecentInput flag — but only for genuine interactions, not scroll or hover side effects. For percentile mechanics and how the 0.1 boundary is evaluated across regions, see understanding Core Web Vitals thresholds.

Prerequisites

Confirm tooling before you measure, so baseline numbers are comparable across runs:

  • Chrome 124+ for the current Performance panel Layout Shift track and the Rendering panel's Layout Shift Regions overlay.
  • web-vitals v4+ (npm i web-vitals) for attribution-enabled field capture (onCLS with the attribution build exposes largestShiftSource).
  • @lhci/cli v0.13+ for Lighthouse CI budget assertions in pull requests.
  • Playwright 1.44+ if you automate multi-viewport regression checks.
  • A staging environment with production-like CDN caching and real third-party tags — lab runs against a tag-free build will systematically under-report CLS.

1. Environment Setup: Reproduce the Shift Deterministically

Layout shift is timing-dependent, so an undisciplined environment produces non-reproducible scores. In DevTools, disable cache, apply 4x CPU throttling and a "Fast 4G" network profile, and record from a cold load. Throttling matters because CLS is driven by the gap between when space should have been reserved and when content arrives; on an unthrottled machine that gap collapses and shifts vanish that real users absolutely see.

Pin the viewport to the breakpoint your RUM data flags as worst — usually mobile, where ad and image reflow have the largest distance fraction. Record three loads and treat the median as your working baseline; a single trace catches network jitter, not a stable signal.

2. Capture Baseline: Field p75 and a Lab Trace Side by Side

Field data is the source of truth, so instrument production first. Capture CLS with web-vitals and ship every entry — including the offending source node — to your beacon endpoint.

javascript
import { onCLS } from 'web-vitals/attribution';

onCLS(({ value, attribution }) => {
  navigator.sendBeacon('/rum', JSON.stringify({
    cls: value,
    largestShiftTarget: attribution.largestShiftTarget,   // CSS selector of worst node
    largestShiftTime: attribution.largestShiftTime,
    loadState: attribution.loadState,
  }));
  // trade-off: the attribution build is ~1KB heavier than core web-vitals.
  // Skip it on bandwidth-critical pages and fall back to plain onCLS, accepting
  // that you lose the per-node selector and must reproduce shifts in the lab instead.
});

Aggregate the beacon stream to a p75 over a rolling 28-day window per device class. If lab CLS reads clean but field p75 exceeds 0.1, the cause is almost always something the lab cannot see: third-party injection, CDN cache-miss latency, or a breakpoint your emulator never hit. That divergence is the signal to widen the investigation rather than trust the green Lighthouse score.

3. Isolate the Bottleneck: Attribute Every Shift to a Node

With a baseline in hand, attribute the dominant shift to a specific node. In the Performance panel, expand the Layout Shifts track; each entry exposes sources with node, previousRect, and currentRect. Cross-reference the shift timestamp against the Main thread to find the script or network response that triggered the reflow.

For a faster visual pass, open the Rendering panel (Ctrl+Shift+P → "Show Rendering") and enable Layout Shift Regions — unstable elements flash with a border proportional to their shift. The timeline-correlation method mirrors how you trace render-blocking work when measuring LCP with Chrome DevTools; the same "displacement → preceding execution block" reasoning applies.

javascript
// Console-only triage: rank live shifts by score and name the worst node.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue;            // exclude user-driven shifts
    const node = entry.sources?.[0]?.node;
    console.log(entry.value.toFixed(3), node);
  }
}).observe({ type: 'layout-shift', buffered: true });
// trade-off: buffered:true replays shifts already fired before the observer attached,
// but it only sees nodes still in the DOM. For nodes removed after shifting (e.g. a
// teardown skeleton) you must catch the entry live, not buffered.

Rank shifts by score and fix the single dominant source before touching anything else. Re-measure after each change — overlapping fixes on a session-windowed metric make it impossible to tell which one moved the number.

4. Apply the Fix: Reserve Deterministic Space

Most shifts trace back to space that was not reserved. The fixes below are ordered by how often they dominate real traces.

Reserve media space with aspect-ratio

The single highest-yield fix is reserving image and video geometry before the bytes arrive. Modern aspect-ratio replaces the padding-hack wrapper and guarantees space across breakpoints. This pairs directly with the sizing discipline in responsive images with srcset and sizes, where correct width/height attributes feed the intrinsic ratio the browser uses before CSS loads.

css
.media-container {
  aspect-ratio: 16 / 9;
  width: 100%;
  max-width: 800px;
  background: #f0f0f0;
}

.media-container img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* trade-off: aspect-ratio reserves the box, but a wrong ratio is worse than none —
   it locks in letterboxing or crops the subject. Don't apply a single global ratio
   to a gallery of mixed-orientation images; derive each box from real dimensions. */

Lazy-loaded images are a frequent regression here: deferring the LCP candidate or omitting reserved dimensions reintroduces shift. The boundary rules for deferring below-the-fold media without harming the largest paint are covered in lazy loading images without hurting LCP.

Stabilize fonts with metric overrides

Font swaps shift text vertically when fallback and web-font metrics diverge. Keep font-display: swap for speed, then align the fallback box with @font-face overrides so the swap is invisible.

css
@font-face {
  font-family: 'CustomWebFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: swap;
  size-adjust: 100%;
  ascent-override: 95%;
  descent-override: 25%;
  line-gap-override: 0%;
}
/* trade-off: font-display: optional eliminates the swap shift entirely, but on slow
   connections the custom font may never paint. Use optional only when brand typography
   is non-essential; otherwise swap + overrides keeps the font and removes the shift. */

Reserve dynamic and third-party slots

Async insertions — comment feeds, recommendation rails, ads — must never enter the flow without pre-allocated height. Use min-height sized to the expected payload and wrap third-party embeds in a fixed box with contain: layout style so their mutations cannot reflow the document. Ad slots are the most adversarial case because the network controls both timing and dimensions; the full slot-reservation and empty-response handling workflow lives in debugging CLS caused by dynamic ad injection.

Hide without collapsing

State-driven visibility toggles shift surrounding content when they remove an element from flow. Use visibility: hidden to hide an element while keeping its box (no collapse), and opacity: 0; position: absolute to remove it from layout entirely without a reflow. Reserve display: none for cases where the collapse is the intended behavior. Route transitions and toggles should animate only transform and opacity, never properties that force synchronous reflow.

Deconstructing CLS Into Its Timing Phases

Treating CLS as one number hides where the budget is being spent. Break each session window into the phases that produce shifts, and assign each a target so you know which phase to attack:

  • Pre-load reservation (target: zero shift). Everything with known geometry — images, fonts, ad slots — should reserve space before first paint. Any shift here is a missing dimension, the cheapest class to fix.
  • Hydration settle (target: < 0.02). Between server HTML and client hydration, mismatched dimensions cause thrash. Server templates must apply identical CSS constraints to the client bundle; conditional rendering that alters DOM structure during hydration is the usual offender.
  • Async content resolution (target: < 0.05 combined). Data-dependent regions — feeds, lazy media, late widgets — resolve after first paint. Skeletons that exactly match final dimensions keep this near zero; mismatched skeletons make it worse than no skeleton at all.
  • Post-interaction (excluded if within 500ms). Genuine user-driven expansion is excluded via hadRecentInput, but a layout change triggered by interaction yet rendered after the 500ms window counts. Keep interaction-driven reflow under that budget.

Sum the phase targets and you stay comfortably under 0.1 with headroom for field variance.

Advanced Diagnostics: Framework and Edge-Case Failure Modes

SSR/SSG hydration mismatches. When server-rendered dimensions differ from client-calculated values — typically a missing viewport width during SSR — hydration triggers layout thrash. Ensure server and client share CSS constraints and avoid structure-altering conditional rendering during hydration.

Client-side route transitions. SPA navigations cause content jumping when data fetches after the route resolves. Render route-level skeletons matching the target grid, defer non-critical fetches to requestIdleCallback or post-first-paint, and animate only transform/opacity.

Container query cascades. @container recalculates layout when parent width changes. If the parent width is derived from async content or JS state, shifts cascade to children. Bound query-dependent containers with explicit min-width/max-width and avoid width: auto.

Long lists and infinite scroll. Unbounded DOM growth destabilizes scroll position. Virtualize with a library such as @tanstack/virtual so off-screen rows do not reflow the viewport as they mount and unmount.

Validation & Budgeting: Gate CLS in CI

Lock the win behind an automated gate so it cannot silently regress. Define a strict budget in lighthouserc.json that fails the build on any regression past 0.1.

json
{
  "ci": {
    "collect": { "settings": { "preset": "desktop" } },
    "assert": {
      "assertions": {
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
      }
    }
  }
}
javascript
// Playwright multi-viewport regression check against staging.
import { test, expect } from '@playwright/test';

for (const width of [375, 768, 1280]) {
  test(`CLS budget @ ${width}px`, async ({ page }) => {
    await page.setViewportSize({ width, height: 900 });
    await page.goto('https://staging.example.com/');
    const cls = await page.evaluate(() => new Promise((resolve) => {
      let total = 0;
      new PerformanceObserver((l) => {
        for (const e of l.getEntries()) if (!e.hadRecentInput) total += e.value;
      }).observe({ type: 'layout-shift', buffered: true });
      setTimeout(() => resolve(total), 4000);
    }));
    expect(cls).toBeLessThan(0.1);
    // trade-off: a fixed 4s wait is simple but flaky for pages with very late async
    // content. For those, wait on a network-idle or app-ready signal instead, or the
    // test will pass before the shifting region even mounts.
  });
}

Close the loop with field alerting: pipe the web-vitals beacon to your APM and alert when p75 CLS crosses 0.1 on any device class. CI catches regressions you can reproduce; field alerting catches the ones — third-party drift, CDN behavior, regional breakpoints — that only appear in the wild.

Common Mistakes

  • Setting width/height attributes but omitting CSS aspect-ratio, so responsive breakpoints still reflow.
  • Shipping web fonts without metric overrides, leaving a visible FOUT-driven vertical shift on swap.
  • Using display: none for async containers, so surrounding content collapses then jumps when data arrives.
  • Inserting DOM nodes with no reserved height or dimension-matched skeleton.
  • Blocking the main thread during hydration, delaying layout stabilization into a longer session window.
  • Trusting a green lab score without correlating against RUM p75 — the divergence is where the real CLS hides.
  • Applying overlapping fixes between measurements, making it impossible to attribute the improvement.