CLS is the only Core Web Vital that tends to get worse as a product matures. Every component you lazy-load, every feature flag that conditionally renders content, every third-party widget you add — each one is a potential layout shift. LCP and INP can be improved incrementally. CLS requires you to think about rendering order before you write the component.
That's what makes it frustrating. The fix is usually architectural, not a one-line change.
Why CLS behaves differently
LCP and INP have clear causes you can trace in DevTools. CLS shifts can happen at any point during a page's life, from any element, triggered by content the browser wasn't expecting to have to reflow around.
The browser assigns each shift a score based on the fraction of the viewport that moved and how far it moved. Shifts that happen within 500ms of a user interaction don't count — those are expected. Everything else does.
Setting width and height on images is table stakes. It helps, but it's not where React apps usually fail CLS.
Hydration layout shift
This is the one that catches Next.js developers off guard.
Server-rendered HTML is delivered with one set of dimensions and content. React hydrates on the client, and if the hydrated output differs from the server HTML — even slightly — the browser has to reflow. That reflow is a layout shift.
The most common trigger is content that depends on something only available in the browser: window.innerWidth, localStorage, a cookie, a user preference stored in context. If a component conditionally renders based on any of these, the server renders one version and the client immediately replaces it.
// This causes a hydration layout shift every time
function Sidebar() {
const [isExpanded, setIsExpanded] = useState(
typeof window !== 'undefined'
? localStorage.getItem('sidebar') === 'expanded'
: false // server always renders collapsed
);
return <aside style={{ width: isExpanded ? 280 : 64 }}>{/* ... */}</aside>;
}
The server renders a 64px sidebar. The client immediately expands it to 280px. Everything to the right shifts left by 216px. CLS score spikes.
The fix is to defer that client-only render until after hydration, so the shift doesn't happen against already-painted content:
function Sidebar() {
const [mounted, setMounted] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
setIsExpanded(localStorage.getItem('sidebar') === 'expanded');
setMounted(true);
}, []);
// Render a placeholder with fixed dimensions until mounted
if (!mounted) {
return <aside style={{ width: 64 }} aria-hidden />;
}
return <aside style={{ width: isExpanded ? 280 : 64 }}>{/* ... */}</aside>;
}
The placeholder has the same dimensions as the default state. The shift from 64px to 280px happens after useEffect, which means it happens after paint — and the browser waits for user interaction before that can affect CLS meaningfully.
The broader principle: any component that renders differently on server vs client needs a stable placeholder with matching dimensions. If you can't give it stable dimensions, defer it entirely with dynamic(() => import('./Component'), { ssr: false }) in Next.js.
Font loading reflow
A font swap causes a layout shift when the fallback font and the web font have different metrics — different character widths, line heights, or letter spacing. The text reflows to fit the new font, and anything below it shifts.
font-display: swap is often recommended because it avoids invisible text. The tradeoff is exactly this: visible text that then shifts. For body text at small sizes the shift is usually small enough to ignore. For large headings, a font swap can move substantial content.
size-adjust closes most of the gap by scaling the fallback font to match the web font's metrics:
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'CustomFontFallback';
src: local('Arial');
size-adjust: 104%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
Getting the values right requires some trial and error against your specific font. The fontaine library automates this for Next.js and Nuxt apps — it generates the adjusted fallback declarations based on the actual font metrics. Next.js 13+ does this automatically for Google Fonts loaded via next/font.
If you're not using next/font for custom fonts, you're probably generating more CLS from font swaps than you realize.
Dynamic content that pushes existing content
Notifications, cookie banners, chat widgets, promotional bars — anything that inserts itself above or around existing content after the initial render is a layout shift.
The browser doesn't penalize shifts that happen within 500ms of a user interaction, but it does penalize shifts triggered by data loading, lazy component mounting, or timeouts. A toast notification that appears 2 seconds after page load and pushes content down by 60px is a CLS event.
The consistent fix is to reserve space before the content loads:
function NotificationBanner() {
const { data: notification } = useNotification();
// Reserve 60px whether or not there's a notification
return (
<div style={{ minHeight: 60 }}>
{notification && <Banner message={notification.message} />}
</div>
);
}
The alternative — animating new content in using transform instead of position changes — avoids CLS entirely because transforms don't affect layout. A banner that slides in from off-screen doesn't move existing content.
// Shifts content — CLS event
<Banner style={{ position: 'relative', top: showBanner ? 0 : -60 }} />
// Doesn't shift content — no CLS
<Banner style={{ transform: `translateY(${showBanner ? 0 : -60}px)` }} />
The transform version renders the element in document flow at its full height from the start, so no other element ever has to reflow around it appearing.
Skeleton screens done wrong
Skeleton screens are supposed to prevent CLS by holding space for incoming content. They cause CLS when the skeleton dimensions don't match the content that replaces them.
A skeleton that renders a single line for what turns out to be a three-line text block. A card placeholder that's 180px tall for a card that renders at 220px. A list skeleton that shows five items when the API returns eight.
// This skeleton will cause a shift if the actual card is taller
function CardSkeleton() {
return <div style={{ height: 180, background: '#eee', borderRadius: 8 }} />;
}
// Better: match the content structure, not just the height
function CardSkeleton() {
return (
<div style={{ padding: 16 }}>
<div style={{ height: 20, width: '60%', background: '#eee', marginBottom: 8 }} />
<div style={{ height: 16, width: '90%', background: '#eee', marginBottom: 4 }} />
<div style={{ height: 16, width: '75%', background: '#eee' }} />
</div>
);
}
The second version matches the structural shape of the content, not just its total height. When the real card renders, the reflow is minimal because the block-level structure is already close to correct.
Dynamic-length content — variable-height text, user-generated content, lists without a fixed item count — is genuinely hard to skeleton correctly. In those cases, the better tradeoff is often SSR with Suspense streaming so the content arrives already rendered, rather than trying to skeleton something you can't predict.
Measuring which elements are causing shifts
The layout-shift entry type in PerformanceObserver gives you the shifted elements and their contribution to the score:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // skip user-triggered shifts
for (const source of entry.sources) {
console.log({
score: entry.value,
element: source.node?.nodeName,
previousRect: source.previousRect,
currentRect: source.currentRect,
});
}
}
}).observe({ type: 'layout-shift', buffered: true });
source.node is the actual DOM element that shifted. previousRect and currentRect show where it was and where it ended up. In practice, a few elements account for most of the CLS score — this surfaces them without needing to reproduce the shift in DevTools.
Running this in production for a few days will tell you which components are responsible and which pages are most affected. The shifts that happen consistently on high-traffic pages are the ones worth fixing first.
CLS is slow to debug because shifts often depend on network timing, font loading, and server response — conditions that don't exist in local development. Measuring from real users is the only reliable way to know where your score is actually coming from.
The next article in this series covers field data collection in depth: what RUM gives you that Lighthouse can't, and how to build a measurement pipeline that makes debugging CLS, INP, and LCP from production tractable.
Top comments (1)
The hydration shift point is where most teams burn the most time. We caught the same pattern in production: PerformanceObserver flagged a theme-class swap as the worst offender. Fix was inlining a pre-hydration script that reads localStorage before React touches the DOM. size-adjust is underused, agree.