CLS From Skeleton Loaders in Suspense Fallback Fix

Cumulative Layout Shift spiking from Suspense skeleton loaders in Next.js 16? Match the skeleton dimensions to real content with a working CSS pattern.
PerformanceCore Web VitalsNext.js
May 6, 20266 min read1027 words

The Problem

A SaaS marketing site I audit quarterly went from 0.04 CLS to 0.21 CLS on mobile in a single deploy. The team had refactored the homepage to use React Server Components with Suspense boundaries around the testimonial carousel, the pricing grid, and the comparison table. Lab Lighthouse scores looked fine because Lighthouse simulates a fast network and the fallbacks barely show. CrUX 28-day caught it: real users on 4G saw the skeleton for 600 to 1100ms, then watched the page jump as the real content swapped in.

If you added Suspense fallbacks with skeleton loaders and your CrUX CLS regressed even though Lighthouse is happy, this is the pattern. The fallback's box height does not match what replaces it, so every Suspense boundary punches a layout shift on the way out.

Why It Happens

CLS measures unexpected layout shifts after First Contentful Paint. The CrUX p75 score is what Search Console and Vitals dashboards report. Three things specific to Suspense fallbacks make this harder than a normal CLS bug:

  1. Suspense fallbacks render synchronously and unmount when the boundary resolves. When the streamed HTML for the resolved component arrives, React replaces the entire fallback subtree. If the real component is taller or shorter than the skeleton, every pixel of difference counts toward CLS for any element below the boundary.
  2. Skeleton libraries default to fixed heights that do not match real content. Most teams reach for a generic <Skeleton height={20} /> row repeated four times. Real content is rarely exactly that height because of font metrics, line wrapping at narrow widths, padding from container queries, or images with intrinsic aspect ratios.
  3. Streaming makes the shift visible. Server Components stream the fallback first because the data fetch is still in flight. On a 4G connection the gap between the fallback paint and the resolution can be 800ms or more. Long enough to count toward CLS, long enough for the user to see the jump, and long enough to register against the interaction window if they are scrolling.

The 0.1 CLS budget for "Good" is tight. One Suspense boundary with a 60px height mismatch and the page below the fold is over budget instantly.

The Fix

Step 1: Reserve the exact final dimensions on the fallback. The skeleton must occupy the same box the real content will. For text content with a known number of lines, use line-clamp-aware sizing. For grids, set the same grid-template-rows on both states.

import { Suspense } from 'react';
import { PricingGrid } from './pricing-grid';

function PricingSkeleton() {
  return (
    <div
      aria-hidden="true"
      className="grid grid-cols-3 gap-6 min-h-[520px]"
      style={{ contain: 'layout' }}
    >
      {Array.from({ length: 3 }).map((_, i) => (
        <div
          key={i}
          className="rounded-2xl bg-zinc-900/60 animate-pulse"
          style={{ aspectRatio: '0.62 / 1' }}
        />
      ))}
    </div>
  );
}

export function Pricing() {
  return (
    <Suspense fallback={<PricingSkeleton />}>
      <PricingGrid />
    </Suspense>
  );
}

The two anchors are min-h-[520px] (matches the resolved grid's measured height at desktop) and aspect-ratio on each card (locks the card height before the data arrives). contain: layout is a CSS Containment hint that tells the browser layout changes inside this box do not leak out. Free CLS insurance.

Step 2: Match the skeleton to the breakpoint, not the desktop default. Mobile is where CLS hurts. Measure the rendered height of the real component on a 360px viewport, not on your laptop. Use container queries or min-h-[] per breakpoint:

<div
  aria-hidden="true"
  className="grid grid-cols-1 md:grid-cols-3 gap-6 min-h-[1280px] md:min-h-[520px]"
  style={{ contain: 'layout' }}
>
  {/* 3 cards stacked on mobile = 3x the height */}
</div>

If you skip this, your desktop CLS looks clean and mobile CrUX silently regresses for two weeks before anyone notices.

Step 3: For dynamic content with unknown final size, render a placeholder that grows, not shrinks. When you genuinely cannot predict the height (server-rendered markdown, user-generated content), reserve more space than you expect and let the real content fill from the top. Browsers do not penalize the fallback being taller than the real content for CLS, only the inverse.

<Suspense fallback={<div style={{ minHeight: '60vh' }} />}>
  <UserContent />
</Suspense>

Step 4: Move below-the-fold Suspense out of the main shift window. CLS only counts shifts within 5 seconds of any user input or for the first 5 seconds of page load. If your skeleton is well below the fold and the user has not scrolled, its resolution will not count. The trick is making sure no above-the-fold skeleton resolves into a different height. Audit boundary by boundary.

Step 5: Verify with the Web Vitals attribution build. The standard web-vitals library tells you the CLS value. The web-vitals/attribution build tells you the exact element that shifted. Wire it once, ship to production, watch real-user data:

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

onCLS((metric) => {
  if (metric.value > 0.1) {
    navigator.sendBeacon('/api/vitals', JSON.stringify({
      metric: 'CLS',
      value: metric.value,
      element: metric.attribution.largestShiftTarget,
      url: location.pathname,
    }));
  }
});

The largestShiftTarget selector is the smoking gun. If it is your Suspense skeleton or the parent of one, you have your culprit. The web-vitals attribution documentation covers the full payload.

The Lesson

Suspense fallbacks shift layout when the skeleton's height does not match what resolves into it. The fix is not a fancier skeleton library, it is measuring the real content at every breakpoint and reserving that exact box on the fallback with min-height, aspect-ratio, and contain: layout. Then verify with attribution-grade real-user monitoring, not Lighthouse.

If your Core Web Vitals regressed after a Server Components refactor and you need the boundaries audited, that is the kind of work I do — see my services. For another related CLS pattern I covered, see Next.js font fallback CLS spike.

Back to blogStart a project