Designing Suspense Fallbacks That Avoid Layout Shift

The regression shows up in field data before anyone notices it locally. Cumulative Layout Shift on the dashboard route moves from 0.02 to 0.18, crossing the “needs improvement” threshold, and the Performance panel attributes the shift to a single element:

Layout Shift  score 0.147
  Element: div.widget-grid > section#insights
  Previous rect:  x 320  y 640  width 780  height 48
  Current rect:   x 320  y 640  width 780  height 412

Height 48 becoming height 412 is a spinner being replaced by a chart. The split was correct, the retry logic is sound, and the user still experiences the page jumping under their cursor a second after it looked settled.

Root cause: the fallback and the component disagree about size

A Suspense boundary swaps one subtree for another. If those two subtrees produce different box dimensions, the swap moves everything below them, and the browser records a layout shift. Nothing about lazy loading causes this on its own — the component-level splitting technique is neutral. What causes it is a fallback authored as an afterthought: fallback={<Spinner />}, in a container with no height, next to a component that renders 412 pixels tall.

There is a second, subtler version. Even when the fallback has a reserved height, a third state — the degraded state rendered by the error boundary described in handling lazy chunk load failures and fallbacks — often does not, so a failed chunk shifts the page even though a successful one does not.

Three States, One Box Without reservation the container is short while loading and tall once loaded, pushing content below it. With reservation, loading, loaded and error states all occupy the same box and nothing moves. Unreserved — content below moves loading: spinner, 48 px content below sits here loaded: chart, 412 px container expands content below pushed down CLS contribution: 0.147 Reserved — nothing moves skeleton: 412 px reserved same box the chart will use content below sits here — and stays error state: same 412 px box a failed chunk shifts nothing either CLS contribution: 0

The fix: reserve on the container, not on the state

The reservation belongs to the wrapper that survives every state change. Whatever renders inside — skeleton, component, or error — then inherits a box that is already correct.

The Reservation Belongs to the Wrapper The wrapper holds the aspect ratio and minimum height; skeleton, loaded component and error state each fill it without defining it. wrapper — aspect-ratio + min-height survives every state change skeleton fills the box loaded component fills the box error state fills the box None of the three defines the size — which is why none of them can shift it
// LazyRegion.jsx — React 18+: one reserved box shared by all three states
import { Suspense } from 'react';

export function LazyRegion({ ratio = '16 / 9', minHeight, children, skeleton, error }) {
  return (
    // The reservation lives here, so it holds no matter which child renders.
    // aspect-ratio scales with the column width; minHeight is the floor.
    <div style={{ aspectRatio: ratio, minHeight, contain: 'layout' }}>
      <ErrorBoundary fallback={error}>
        <Suspense fallback={skeleton}>{children}</Suspense>
      </ErrorBoundary>
    </div>
  );
}

contain: layout is worth including: it tells the browser that nothing inside the box can affect layout outside it, which prevents a late-arriving child from influencing ancestors even if its own internal layout settles in stages.

For components whose height depends on their width — charts, media, card grids — aspect-ratio is the correct reservation. A fixed pixel height that is right at 1280 px is wrong at 390 px, and the shift simply moves to mobile, where it counts for more.

/* widget.css — reservation that survives every breakpoint */
.widget-slot {
  aspect-ratio: 16 / 9;
  min-height: 220px;      /* floor for very narrow viewports */
  contain: layout;
}

/* The skeleton fills the reserved box rather than defining it. */
.widget-skeleton {
  width: 100%;
  height: 100%;
  border-radius: 8px;
  background: var(--color-bg-alt);
}

@media (prefers-reduced-motion: no-preference) {
  .widget-skeleton { animation: skeleton-pulse 1.6s ease-in-out infinite; }
}

@keyframes skeleton-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: .6; }
}

Making the skeleton match the real layout

A reserved box removes the shift; a structured skeleton removes the sense of the page redrawing. The difference is whether the skeleton mirrors the component’s internal geometry — a title bar, a legend row, a plot area — or is a single grey rectangle of the right size.

Structured skeletons cost more to maintain, so apply them selectively: use them for regions that occupy a large share of the viewport, where a solid block reads as a broken page, and use plain reserved boxes for smaller widgets where the distinction is imperceptible.

The rule that always applies is that the skeleton must not animate layout. Pulsing opacity is free; animating width or height reintroduces the exact instability the reservation exists to prevent, and it is reported as shift.

Structured Skeleton Versus Loaded Component The skeleton reproduces the component's title bar, legend row and plot area at the same positions, so the transition to the loaded chart changes only the content within each region. Skeleton Loaded component Weekly revenue ▪ actual ▪ forecast Same regions, same positions — only the contents change

Step-by-step verification

  1. Reproduce the shift locally. Throttle the network to a slow mobile profile so the fallback actually paints. Without throttling, the bug is invisible in development.

  2. Record a layout-shift trace. With the Performance panel recording, load the route and confirm no shift entries name the lazy region’s container.

  3. Check every breakpoint. Repeat at a narrow viewport. A fixed-height reservation that passes on desktop commonly fails here.

  4. Force the error state. Block the chunk and confirm the degraded state occupies the same box. This is the state teams most often forget to reserve.

  5. Confirm the skeleton does not animate layout. Inspect the animation: only opacity or background position should change, never width, height, margin, or padding.

  6. Watch the field metric. Confirm the route’s CLS at the 75th percentile stays under 0.1 after release, using the field instrumentation described in measuring real-user chunk loading performance.

Edge cases and gotchas

Content-dependent height. A list whose height depends on how many rows the response returns cannot be reserved exactly. Reserve a sensible minimum matching the common case and accept a small shift in the tail, or render a fixed number of skeleton rows and paginate.

Fonts arriving after the component. A component sized by text can settle at one height with the fallback font and another once the web font loads, producing a shift that has nothing to do with the chunk. Size-adjust descriptors on the fallback font remove it.

Nested boundaries. Two nested Suspense boundaries produce two swaps, and each needs its own reservation. The outer box holding steady does not help if an inner region collapses.

Above-the-fold regions. A shift in the initial viewport counts more heavily than one below the fold, because the impact fraction is larger. Prioritise reservations for regions the user can see immediately — and consider whether an above-the-fold region should be lazy at all.

FAQ

Why does my lazy component cause layout shift only in production?

Because in development the chunk resolves in a few milliseconds from a warm local server, so the fallback is never painted and no shift is observable. In production the chunk takes long enough to paint the fallback, the layout settles around its smaller box, and then the real component expands it. The shift was always in the code; only production is slow enough to reveal it. Throttling the network locally reproduces it immediately.

Should the reserved height be fixed or aspect-ratio based?

Use aspect-ratio whenever the component’s height is a function of its width, which covers charts, media, and most card grids — a fixed pixel height that is correct on desktop is wrong on a narrow viewport and reintroduces the shift there. Reserve fixed heights only for components whose height genuinely does not vary with width, such as a single-row toolbar or a fixed-size control panel.

Does a spinner count as a layout-shift-safe fallback?

Only if it is centred inside a container that already reserves the loaded component’s box. A bare spinner in an auto-height container is one of the most reliable ways to produce a shift, because the container collapses to the spinner’s size and then expands. The spinner itself is not the problem — the missing reservation on its container is.