CLS Reduction Strategies
Cumulative Layout Shift is the only Core Web Vital that accumulates over the entire page lifetime rather than capturing a single moment, which makes it uniquely sensitive to asynchronous behaviour that lab tools rarely reproduce: a font swap at 1,200 ms, an ad iframe that paints late, a cookie banner injected after hydration. This reference, building on Core Web Vitals & Performance Metrics Fundamentals, covers how the layout shift score is computed, how to capture it correctly in the field with the PerformanceObserver and web-vitals instrumentation patterns, and the concrete DOM and CSS fixes that move a page from Poor to Good against real-user p75 data.
How the layout shift score is actually computed
CLS is not a count of shifts or a sum of pixel movement. Each unexpected shift produces a layout shift score defined as impact fraction × distance fraction:
- The impact fraction is the union of the visible area an unstable element occupied in the previous frame and the current frame, expressed as a fraction of the viewport. An element that filled the top 30% of the screen before moving and 35% after, with overlap, has an impact fraction around 0.35.
- The distance fraction is the greatest distance any unstable element moved, divided by the larger viewport dimension (width or height). An element that drops 90 px in a 900 px viewport has a distance fraction of 0.1.
Multiply them: 0.35 × 0.1 = 0.035 for that single shift. Only unexpected shifts count — anything within 500 ms of a user interaction is flagged hadRecentInput: true and excluded, because the user caused it.
Individual scores are summed inside session windows: a window opens at the first shift and extends until there is a 1 second gap with no shifts, capped at a maximum window length of 5 seconds. CLS is the sum of the worst (largest) session window, not the sum of all shifts on the page. This windowing is why a single late-loading ad cannot ruin a session that otherwise had its shifts spread out, and why a burst of shifts during hydration is the dangerous pattern. The headline CLS your team is judged on is the p75 of this per-session value across real users.
Threshold configuration and engineering action
Thresholds are fixed unitless scores. Map each band directly to a remediation posture in your tracking pipeline and your alerting rules.
| Threshold band | CLS score (p75) | Engineering action |
|---|---|---|
| Good | ≤ 0.10 | Hold the line: lock dimensions into CI assertions so regressions cannot reopen. |
| Needs Improvement | > 0.10 and ≤ 0.25 | Segment by device and route; reserve space for the top one or two shift sources. |
| Poor | > 0.25 | Block the release gate; trace the worst session window and fix the dominant source first. |
Because the user experience is judged at p75 of the per-session worst window, a low median with a heavy tail still fails. A page can show CLS 0.02 in the lab and 0.28 at p75 in the field when a third of users hit a slow font swap or an ad that paints below the fold then reflows into view on scroll.
Capturing CLS correctly in production
The minimum correct implementation observes layout-shift entries, filters hadRecentInput, accumulates them into session windows, and finalizes the value when the page is hidden — not on load, because shifts continue to accrue for the entire session. Use a buffered: true observer so entries that fired before your script ran are not lost.
// Self-contained CLS collector using the session-window algorithm.
let clsValue = 0;
let sessionValue = 0;
let sessionEntries = [];
function reportCLS() {
const payload = JSON.stringify({
metric: 'CLS',
value: clsValue,
page: location.pathname,
// Largest source in the worst window, for attribution.
worstSource: sessionEntries.length
? describeNode(sessionEntries[0].sources?.[0]?.node)
: null,
});
navigator.sendBeacon('/api/vitals', payload);
}
function describeNode(node) {
if (!node) return 'unknown';
return node.id ? `#${node.id}` : node.tagName?.toLowerCase() || 'unknown';
}
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // exclude user-driven shifts
const first = sessionEntries[0];
const last = sessionEntries[sessionEntries.length - 1];
// Same session window if < 1s since last shift and < 5s since first.
if (
sessionValue &&
entry.startTime - last.startTime < 1000 &&
entry.startTime - first.startTime < 5000
) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}
// CLS is the maximum session window observed so far.
if (sessionValue > clsValue) {
clsValue = sessionValue;
// keep sessionEntries as the worst window for attribution
}
}
});
po.observe({ type: 'layout-shift', buffered: true });
// Finalize when the page is backgrounded or unloaded — never earlier.
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
po.takeRecords(); // flush queued entries into the callback first
reportCLS();
}
}, { once: true });
For most teams the production-grade path is to delegate this algorithm to the maintained library rather than hand-roll it. The web-vitals attribution build implements the exact session-window logic above and surfaces largestShiftTarget, largestShiftSource, and loadState, so a single beacon carries both the score and the DOM node responsible:
import { onCLS } from 'web-vitals/attribution';
onCLS(({ value, attribution }) => {
navigator.sendBeacon('/api/vitals', JSON.stringify({
metric: 'CLS',
value,
largestShiftTarget: attribution.largestShiftTarget ?? 'unknown',
largestShiftTime: attribution.largestShiftTime,
loadState: attribution.loadState, // dom-interactive, complete, etc.
}));
});
Tracking loadState is what lets you separate shifts during initial parse from shifts after hydration, which usually require completely different fixes.
The five sources of layout shift, and their fixes
Almost every field CLS regression traces to one of five sources. The fix in each case is to reserve the final geometry before the content arrives.
| Shift source | Why it shifts | Primary fix |
|---|---|---|
| Images / iframes without dimensions | Box is 0×0 until the resource decodes, then content below jumps | width/height attributes or CSS aspect-ratio |
| Web fonts (FOUT / FOIT) | Fallback metrics differ from the web font; swap reflows text | size-adjust / font-display: optional, matched fallback |
| Async ad / embed injection | Slot has no height until the creative loads | Reserve min-height matching the creative |
| Dynamically inserted DOM | Banners, toasts, “you may also like” rows push content down | Insert above the fold’s reserved region, or overlay |
| Late layout-affecting CSS | A stylesheet or class arrives after first paint and re-flows | Inline critical CSS, avoid post-load layout class toggles |
Reserve space for media
The single highest-leverage fix. Setting width and height attributes lets the browser compute an aspect ratio and reserve the box before the bytes arrive, even with fully responsive CSS.
<!-- width/height let the UA reserve the box; CSS still scales it -->
<img src="/hero.avif" width="1280" height="720" alt="" style="width:100%;height:auto">
<!-- For containers where intrinsic dimensions are unknown, declare the ratio -->
<style>
.video-embed { aspect-ratio: 16 / 9; width: 100%; }
</style>
Stabilize web fonts
A web font whose glyph metrics differ from the fallback reflows every line of text when it swaps. The robust fix is to match the fallback’s metrics to the web font using size-adjust, ascent-override, and descent-override so the swap is metrically invisible. The deeper treatment lives in Preventing CLS from Web Font Loading.
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: swap;
}
/* A fallback tuned so the swap doesn't change line box height */
@font-face {
font-family: "Inter-fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
}
body { font-family: "Inter", "Inter-fallback", sans-serif; }
Contain dynamic and ad regions
Ad and embed slots are the classic Poor-CLS source because their height is unknown until the network responds. Reserve the slot, isolate its reflow scope with CSS containment, and never let it expand its parent. The end-to-end ad pattern — including deferred initialization and collapse handling — is covered in Reducing CLS from Dynamic Ad Injections.
.ad-slot {
min-height: 250px; /* match the smallest expected creative */
aspect-ratio: 300 / 250;
contain: layout style; /* reflow stays inside the slot */
}
Animate with transforms, not layout properties
Any animation that drives top, left, width, height, or margin mutates layout and can register as a shift. Animate transform and opacity instead — they run on the compositor and never trigger a layout shift entry.
/* Shifts layout — avoid */
.toast-bad { transition: top 200ms; }
/* Compositor-only — no CLS */
.toast-good { transition: transform 200ms; transform: translateY(0); }
Step-by-step debugging workflow
- Identify the worst window. Query field data for sessions at and above p75 CLS, grouped by route and
loadState. ReadlargestShiftTargetto find the dominant DOM node — fix that before anything else. - Trace the waterfall. In the DevTools Performance panel, record a load with CPU and network throttling matched to your worst field segment, then read the Layout Shifts track to see which frame each shift lands on and what painted just before it.
- Correlate overlaps. Line up shift timestamps against resource finish times (font swap, ad response, hydration commit). The resource that completes immediately before a shift is almost always its cause.
- Validate in lab. Reproduce the shift with throttling, apply the dimension-reservation fix locally, and confirm the Layout Shifts track is empty for that window. Lab confirmation prevents shipping a fix that only works on fast hardware.
- Deploy behind a flag. Roll the fix to a fraction of traffic tagged with a deployment marker so the before/after is directly comparable in the same population.
- Monitor the delta. Watch p75 CLS for the affected route over the following days; a real fix moves the p75, not just the median. If only the median moves, a tail segment still shifts — return to step 1 for that segment.
Field-data analysis patterns
A single global CLS number hides the segments that actually fail. Always segment, because layout shift causes are strongly correlated with environment.
- Device class. Low-end Android reflows later and slower; fonts and ads land after first paint far more often than on desktop. A page that is Good on desktop and Poor on mobile points at late-arriving resources, not your markup.
- Network type. On slow effective connection types (
2g,slow-2g), images and ad creatives finish well after layout settles, so unreserved boxes shift much later. Segment bynavigator.connection.effectiveTypecaptured at beacon time. - Geography. Edge distance changes when third-party scripts arrive, so CLS attributable to ads and embeds varies by region even on identical markup.
Watch for divergences where the p75 of one segment exceeds the next band while the aggregate stays Good — that is the population Google’s field assessment will weight. Aggregate at p75 per segment, never with a mean, which a few clean sessions can flatter. The sampling and percentile mechanics are detailed in RUM Data Sampling Strategies.
-- p75 CLS by route and device class, last 7 days
SELECT
page,
device_class,
quantile(0.75)(cls_value) AS p75_cls,
count() AS sessions
FROM rum_events
WHERE metric_name = 'CLS'
AND event_time > now() - INTERVAL 7 DAY
GROUP BY page, device_class
HAVING sessions > 500
ORDER BY p75_cls DESC;
Failure modes and gotchas
- SPA route transitions. The layout shift score does not reset on client-side navigation; the spec’s CLS is per page load. Shifts after a
pushStateroute change keep accruing into the same session value, so a snappy client route that reflows on every transition inflates CLS even though no real navigation occurred. Reset your own per-route accumulator on route change while keeping the page-level value for reporting. - Back/forward cache (bfcache) restores. A page restored from bfcache fires
pageshowwithpersisted: trueand starts a fresh layout shift score. If you finalized and beaconed onpagehide, re-arm your collector on bfcache restore or you will silently stop measuring returning users. visibilitychangeis the finalize hook, notunload.unloaddoes not fire reliably on mobile and breaks bfcache eligibility. Finalize on the first transition tohiddenand calltakeRecords()to flush queued entries before beaconing.- Safari and observer gaps. Safari’s
layout-shiftsupport trails Chromium; treat the absence of entries as “no data,” not “no shift,” and keep field assessment Chromium-weighted as Google does. - Background-tab suspension. Entries can queue while a tab is backgrounded and arrive in a burst on refocus. The 1-second session-gap rule usually splits these correctly, but verify your accumulator does not merge a pre-background and post-refocus shift into one inflated window.
Layout instability frequently co-occurs with slow interactions, since both surface during hydration; when a shift fires right after an input, cross-check INP Tracking & Debugging to confirm whether delayed input handling is what made the shift visible. Likewise, an oversized hero that reserves no space will shift content and is often the same element flagged by LCP Measurement & Optimization.
CI/CD integration
Gate CLS in the regression pipeline so a reintroduced un-sized image fails the build before it reaches the field. Run a Lighthouse or Playwright trace against a representative route, throttled to a slow profile, and assert the layout shift score stays inside the Good band. Treat the lab number as a guardrail and the field p75 as the source of truth for release decisions.
# Fail the build if lab CLS exceeds the Good threshold.
npx lighthouse https://staging.example.com/article \
--only-categories=performance \
--throttling-method=simulate \
--output=json --output-path=./lh.json --quiet
node -e '
const r = require("./lh.json");
const cls = r.audits["cumulative-layout-shift"].numericValue;
console.log("Lab CLS:", cls.toFixed(3));
if (cls > 0.1) { console.error("CLS gate failed (> 0.1)"); process.exit(1); }
'
FAQ
Why is my field CLS high when Lighthouse reports it as Good?
Lab tools measure a single scripted load on fast hardware and stop early; the field value is the worst session window across the full page lifetime at p75. Late font swaps, below-the-fold ads that reflow on scroll, and low-end-device timing only appear in real-user data. Trust the field-segmented p75, not the lab snapshot.
Do shifts after a user clicks count toward CLS?
No. Any shift within 500 ms of a discrete input is flagged hadRecentInput: true and excluded, because it is considered user-initiated. This is why scroll-triggered and click-triggered expansions are not penalized — but a shift caused by an async resource that merely coincides with idle time still counts.
Does CLS reset on a single-page-app route change?
Not by spec — the layout shift score is per page load and keeps accumulating across client-side navigations. You must maintain your own per-route accumulator and reset it on pushState/route change while still reporting the page-level value through your collector.
What is the single highest-impact CLS fix?
Setting width and height attributes (or CSS aspect-ratio) on every image, video, and iframe. It lets the browser reserve the final box before bytes arrive and eliminates the most common source of shift with no runtime cost.
Why finalize CLS on visibilitychange instead of load?
Because shifts accrue for the entire session, not just until load. Reporting at load misses every shift from late ads, fonts, and post-hydration DOM. Finalizing on the first transition to hidden (with takeRecords() to flush) captures the true worst window before the page is discarded.
Related
- Reducing CLS from Dynamic Ad Injections — reserve and contain ad slots so late creatives never reflow the page.
- Preventing CLS from Web Font Loading — metric-matched fallbacks and font-display to make the swap invisible.
- LCP Measurement & Optimization — the hero element that shifts is often the same one that drives LCP.
- FCP & TTFB Analysis — upstream timing that determines when layout-affecting resources arrive.
- User Impact Mapping — translate CLS regressions into conversion and bounce impact.