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.
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.
// 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.
Step-by-step verification
-
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.
-
Record a layout-shift trace. With the Performance panel recording, load the route and confirm no shift entries name the lazy region’s container.
-
Check every breakpoint. Repeat at a narrow viewport. A fixed-height reservation that passes on desktop commonly fails here.
-
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.
-
Confirm the skeleton does not animate layout. Inspect the animation: only opacity or background position should change, never width, height, margin, or padding.
-
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.
Related
- Handling Lazy Chunk Load Failures and Fallbacks — the parent guide covering the error state this page sizes
- Retrying Failed Dynamic Imports With Exponential Backoff — what happens during the window the skeleton is on screen
- Component-Level Code Splitting Beyond Routes — choosing which components to defer in the first place
- Code Splitting Chart Libraries in Dashboards — the widget category most prone to this failure