Cumulative Layout Shift is the Core Web Vital that most often looks fine on your machine and fails in the field. Your fonts are cached, your images are warm, your connection is fast — so nothing moves. On a cold visit over a mid-range phone connection, everything moves.
The underlying cause is always the same: content arrived and the browser had not reserved space for it. Every fix is some version of reserving that space in advance.
Find the shifts before fixing anything
CLS is a sum. Usually one or two shifts dominate and the rest are noise, so start by identifying them rather than hardening everything.
In Chrome DevTools, record a page load in the Performance panel and look at the Experience track. Each shift appears as a marker; selecting one names the element that moved and how much it contributed.
For real sessions, the Layout Instability API reports the same data from the field:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // user-initiated shifts don't count
console.log(entry.value, entry.sources.map((s) => s.node));
}
}).observe({ type: "layout-shift", buffered: true });The sources array is the useful part — it points at the nodes that actually moved, which is frequently not the element you suspected.
Images: dimensions are not optional
An image without dimensions occupies zero height until it loads, then suddenly occupies several hundred pixels and pushes everything below it down.
With a static import, next/image reads the intrinsic size at build time and reserves the space for you:
import Image from "next/image";
import cover from "../public/cover.webp";
<Image src={cover} alt="…" className="w-full" /> // dimensions known, space reservedWith a remote source, nothing is known at build time, so you supply it:
<Image src={url} alt="…" width={1200} height={630} className="h-auto w-full" />The width and height are an aspect ratio, not a display size — CSS still controls how large it renders. Their job is to let the browser compute the correct height before a single byte arrives.
For images of unknown ratio, reserve the box yourself and crop into it:
<div className="relative aspect-[16/9] w-full">
<Image src={url} alt="…" fill className="object-cover" />
</div>fill requires a positioned parent with its own dimensions. Without one, you are back to reserving nothing.
Fonts: swap without a jolt
A web font loading after first paint re-flows every line of text it touches. The severity depends on how different the fallback's metrics are: if the fallback is narrower, lines re-wrap and everything below moves.
next/font handles the hard part automatically. It self-hosts the font, preloads it, and generates a size-adjusted fallback whose metrics are matched to the real font, so the swap is close to invisible:
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"], display: "swap", variable: "--font-inter" });Two things to check. Load only the weights and subsets you use — each one is a download competing with first paint. And confirm no @import from a font CDN survives in your CSS, because that reintroduces exactly the round trip next/font removed. I went through that in more detail in how I cut First Contentful Paint.
Client-only content: the guaranteed shift
This is the Next.js-specific one, and it is nearly always self-inflicted.
A component that renders nothing on the server and real content after mount produces a shift every single time:
// ❌ renders null on the server, content after hydration — a shift on every load
"use client";
export function ThemeToggle() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <button>…</button>;
}The usual motivation is avoiding a hydration mismatch — the server cannot know the user's theme, locale, or viewport. The fix is not to render nothing; it is to render something of the same size:
// ✅ occupies its final size from the first paint
if (!mounted) return <span className="block size-11" aria-hidden />;Better still, avoid the mismatch entirely. For a theme toggle, render both icons and let CSS pick one, so server and client markup are identical and nothing has to wait for mount:
<svg className="dark:hidden" />
<svg className="hidden dark:block" />Same applies to next/dynamic with ssr: false — give the component a loading placeholder sized like the real thing, not null.
Everything else that arrives late
- Ads and embeds. Reserve the slot at its known size. A collapsed slot that expands when the ad fills is pure CLS, and it is the most common failure on content sites.
- Banners and notices. Cookie bars and announcement strips inserted at the top of the document push the entire page down. Overlay them, or render them in the initial HTML so they are part of the first layout.
- Web-font icons. An icon font that swaps in late shifts inline text. Inline SVG does not.
@font-facewithoutsize-adjust. If you hand-roll font faces rather than usingnext/font, you own the metric matching yourself.
Measure the way Google does
CLS accumulates across the whole page visit, not just load. A shift triggered by something loading five seconds in still counts — which is why lazy content below the fold matters even though nobody has scrolled to it.
Test on a throttled connection with an empty cache, because warm caches hide nearly every shift on this list. Then check Search Console's Core Web Vitals report for the 75th percentile of real sessions, and treat that as the verdict over any lab score. The broader loop for this — lab to test a change, field to confirm it mattered — is in my Next.js performance guide.
The takeaway
Every entry on this list is the same instruction in a different costume: reserve the space before the content arrives.
Images get dimensions or an aspect-ratio box. Fonts get a metric-matched fallback. Client-only components get a placeholder the size of the real thing. Ads and banners get their slot in the initial layout. Do that consistently and CLS stops being a score you chase and becomes a property the layout has by construction.