DEV Community

Cover image for Why CLS Is Harder to Fix in React Than You Think
nosyos
nosyos

Posted on • Edited on

Why CLS Is Harder to Fix in React Than You Think

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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%;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)` }} />
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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.


Community patterns from production

A few additional patterns from the comments, all caught with PerformanceObserver:
Bilingual length variance — Russian content runs ~25–30% longer than English on average, causing EN single-line strings to wrap in RU. Rendering layout calculations against the longer-language baseline eliminates the locale-toggle shift.
YouTube embeds — Reserve aspect ratio before the iframe loads:

css.youtube-embed-wrapper {
  position: relative;
  padding-bottom: 56.25%;
  overflow: hidden;
}
.youtube-embed-wrapper iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
Enter fullscreen mode Exit fullscreen mode

Translation cache hydration — If translations are lazy-loaded on locale toggle, warm the cache eagerly on page load instead. The first fetch on toggle is where the shift lands.
— via @arvavit

Top comments (4)

Collapse
 
arvavit profile image
Vadym Arnaut

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.

Collapse
 
nosyos profile image
nosyos

Thank you so much. That's a really insightful observation.

You're absolutely right that the hydration shift is the biggest time sink. The concrete example of detecting theme-class swap with PerformanceObserver and fixing it with a pre-hydration localStorage read is incredibly valuable.

The key point you mention—"reading localStorage before React touches the DOM"—that's exactly what many teams miss. I think this is the kind of detail that gets overlooked even when developers notice CLS is happening.

Your practical experience with this pattern really comes through, especially regarding size-adjust. I'd love to include this exact approach in the article as a concrete example.

Do you have any other production patterns like this? I'd be grateful to learn more about how you've handled similar cases.

Collapse
 
arvavit profile image
Vadym Arnaut

A few more from our LMS production:

Bilingual length variance. Russian content runs ~25-30% longer than English on average. Every UI string that fits on one line in EN wraps to two in
RU. We render layout against the longer-language baseline for size calculation, swap text post-mount. Kills the locale-toggle shift cleanly.

YouTube embeds. Standard 56.25% padding-bottom on a position: relative wrapper with the iframe absolute-positioned inside. Aspect ratio reserved
before iframe loads. Cheap, eliminates a chunk of reader-view CLS:

  .youtube-embed-wrapper {
    position: relative;
    padding-bottom: 56.25%;
    overflow: hidden;
  }
  .youtube-embed-wrapper iframe {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
  }
Enter fullscreen mode Exit fullscreen mode

Translation cache hydration. Auto-translated content is keyed by (entity, field, locale). First read in a new locale triggers fetch — that's where
the shift lands. Fix was warming the cache eagerly on course load, not lazily on locale toggle.

PerformanceObserver caught all of these. Same playbook as the theme-class swap.

Thread Thread
 
nosyos profile image
nosyos

These are gold, especially the bilingual length variance approach. The idea of rendering against the longer-language baseline is something I hadn't considered — that's a clean solution to a problem that's easy to miss until you ship to a second locale.
The YouTube wrapper pattern is one I'll definitely add to the article. It's the kind of thing that's obvious in hindsight but gets forgotten in practice.
Thanks for sharing these. Genuinely useful.