Skip to main content

Coordinated Fallbacks: Showing Nested Loading States Correctly

Coordinated fallbacks are the key to a polished user experience with nested Suspense boundaries. Instead of showing independent spinners for each data chunk, you can orchestrate which fallbacks display when, and in what order. This prevents UI fragmentation and allows graceful degradation: show a full-page skeleton first, then progressively fill sections as data arrives.

Understanding boundary nesting, fallback visibility, and the order of promise resolution lets you build loading experiences that feel intentional and responsive.

Understanding Suspense Boundary Nesting

When Suspense boundaries are nested, an outer boundary's fallback only shows if all inner boundaries have unresolved promises. Once any inner boundary resolves, it displays content, and the outer fallback becomes irrelevant:

import { use, Suspense } from 'react';

function Header({ headerPromise }) {
const header = use(headerPromise);
return <header>{header.title}</header>;
}

function Sidebar({ sidebarPromise }) {
const sidebar = use(sidebarPromise);
return <aside>{sidebar.content}</aside>;
}

function MainContent({ contentPromise }) {
const content = use(contentPromise);
return <main>{content.body}</main>;
}

export function Page() {
const headerPromise = fetch('/api/header').then(r => r.json());
const sidebarPromise = fetch('/api/sidebar').then(r => r.json());
const contentPromise = fetch('/api/content').then(r => r.json());

return (
<Suspense fallback={<div>Loading page...</div>}>
<Header headerPromise={headerPromise} />

<Suspense fallback={<div>Loading sidebar...</div>}>
<Sidebar sidebarPromise={sidebarPromise} />
</Suspense>

<Suspense fallback={<div>Loading content...</div>}>
<MainContent contentPromise={contentPromise} />
</Suspense>
</Suspense>
);
}

In this setup:

  • The outer <Suspense fallback={<div>Loading page...</div>}> shows only if Header is still loading (its promise is pending).
  • Once Header resolves, the outer fallback is replaced with the header content.
  • The sidebar and content fallbacks display independently inside their own boundaries.
  • If sidebar finishes before content, the sidebar appears while the content fallback is still showing.

This creates a cascading effect: data arrives in chunks, and each chunk displays as soon as it's ready.

Designing Fallback UI for Cascading Display

A well-designed fallback matches the shape of the content it's replacing. This prevents layout shifts and guides the user's eye:

// Layout-aware fallbacks
function HeaderSkeleton() {
return (
<header style={{ padding: '1rem', background: '#f0f0f0' }}>
<div style={{ height: '2rem', background: '#ddd', width: '200px' }} />
</header>
);
}

function SidebarSkeleton() {
return (
<aside style={{ padding: '1rem', width: '300px' }}>
{[1, 2, 3].map(i => (
<div key={i} style={{ height: '1rem', background: '#ddd', marginBottom: '0.5rem' }} />
))}
</aside>
);
}

function ContentSkeleton() {
return (
<main style={{ padding: '2rem' }}>
{[1, 2, 3, 4].map(i => (
<div key={i} style={{ height: '1rem', background: '#ddd', marginBottom: '0.5rem' }} />
))}
</main>
);
}

export function Page() {
const headerPromise = fetch('/api/header').then(r => r.json());
const sidebarPromise = fetch('/api/sidebar').then(r => r.json());
const contentPromise = fetch('/api/content').then(r => r.json());

return (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Suspense fallback={<HeaderSkeleton />}>
<Header headerPromise={headerPromise} />
</Suspense>

<div style={{ display: 'flex' }}>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar sidebarPromise={sidebarPromise} />
</Suspense>

<Suspense fallback={<ContentSkeleton />}>
<MainContent contentPromise={contentPromise} />
</Suspense>
</div>
</div>
);
}

Each skeleton has the same structure (padding, height, colors) as the content, so the transition is smooth. Users experience a visual continuity, not a jarring jump from skeleton to content.

Controlling Promise Resolution Order

Sometimes you want certain data to load before displaying the page. This is called "sequential boundary coordination." You can achieve it by nesting boundaries strategically:

// Priority: Header > (Sidebar + Content)
// Header must load before showing page at all
// Sidebar and Content load in parallel after header

function SequentialPage() {
const headerPromise = fetch('/api/header').then(r => r.json());

return (
<Suspense fallback={<div>Loading page...</div>}>
{/* Header is critical; show fallback until it loads */}
<Header headerPromise={headerPromise} />

{/* Only after header is visible, allow sidebar/content to load independently */}
<SidebarAndContent />
</Suspense>
);
}

function SidebarAndContent() {
const sidebarPromise = fetch('/api/sidebar').then(r => r.json());
const contentPromise = fetch('/api/content').then(r => r.json());

return (
<div style={{ display: 'flex' }}>
<Suspense fallback={<div>Loading sidebar...</div>}>
<Sidebar sidebarPromise={sidebarPromise} />
</Suspense>

<Suspense fallback={<div>Loading content...</div>}>
<MainContent contentPromise={contentPromise} />
</Suspense>
</div>
);
}

Here, SidebarAndContent is only rendered after the header promise resolves. The sidebar and content fetch in parallel but don't start until after the header is visible. This sequence prevents a long blank screen and prioritizes the most important content.

Avoiding Over-Nesting: When to Use One Boundary

Not every async operation needs its own boundary. If two pieces of data are logically related, fetch them together and use one boundary:

// Wrong: Over-nested boundaries
function UserWithPosts({ userId }) {
return (
<Suspense fallback={<div>Loading user...</div>}>
<User userId={userId} />
<Suspense fallback={<div>Loading posts...</div>}>
<UserPosts userId={userId} />
</Suspense>
</Suspense>
);
}

// Better: One boundary for related data
function UserWithPosts({ userId }) {
const userPromise = fetch(`/api/users/${userId}`).then(r => r.json());
const postsPromise = fetch(`/api/users/${userId}/posts`).then(r => r.json());

return (
<Suspense fallback={<div>Loading user and posts...</div>}>
<User userPromise={userPromise} />
<UserPosts postsPromise={postsPromise} />
</Suspense>
);
}

In the "Better" version, both fetches start in parallel, and one fallback shows for both. This is cleaner and prevents the UI from flickering between multiple states.

Advanced Pattern: Skeleton with Progressive Enhancement

Combine a skeleton fallback with progressive updates using startTransition and useState:

import { use, Suspense, startTransition, useState } from 'react';

function ContentDetail({ contentPromise }) {
const [showPlaceholder, setShowPlaceholder] = useState(true);
const content = use(contentPromise);

// Once use() returns, content is loaded; hide placeholder
React.useEffect(() => {
setShowPlaceholder(false);
}, [content]);

return showPlaceholder ? (
<div className="skeleton" />
) : (
<article>{content.body}</article>
);
}

export function Page({ contentId }) {
const contentPromise = fetch(`/api/content/${contentId}`).then(r => r.json());

return (
<Suspense fallback={<div className="page-skeleton" />}>
<ContentDetail contentPromise={contentPromise} />
</Suspense>
);
}

This pattern shows a skeleton immediately, then the real content once loaded. It's useful for avoiding flashing or transitions that feel too sudden.

Common Pitfall: Boundary Visibility and Timing

A common mistake is placing a boundary inside a component that will unmount while waiting:

// Wrong: boundary inside a component that might unmount
function Page({ showDetails }) {
return (
<div>
{showDetails && (
<Suspense fallback={<div>Loading details...</div>}>
<Details detailsPromise={detailsPromise} />
</Suspense>
)}
</div>
);
}

// When user toggles showDetails to false, the boundary disappears, and the promise is abandoned.
// If showDetails is toggled true again, a new promise is created, and the old one is lost.

Better approach: move the boundary outside the conditional:

// Better: boundary always present
function Page({ showDetails }) {
return (
<Suspense fallback={showDetails ? <div>Loading details...</div> : null}>
{showDetails && <Details detailsPromise={detailsPromise} />}
</Suspense>
);
}

Now the boundary persists, and the promise is maintained even if the component toggles.

Using useTransition with Nested Boundaries

Coordinate fallback visibility with startTransition to avoid showing fallbacks for fast updates:

import { use, Suspense, useTransition } from 'react';

function DashboardContent({ dataPromise }) {
const data = use(dataPromise);
return <div>{data.summary}</div>;
}

export function Dashboard({ dataId }) {
const [isPending, startTransition] = useTransition();
const [dataPromise, setDataPromise] = useState(() => fetchData(dataId));

function handleRefresh() {
startTransition(() => {
setDataPromise(fetchData(dataId));
});
}

return (
<div>
<button onClick={handleRefresh} disabled={isPending}>
{isPending ? 'Refreshing...' : 'Refresh'}
</button>

{/* Fallback only shows if isPending for more than 500ms */}
<Suspense fallback={<div>Updating...</div>}>
<DashboardContent dataPromise={dataPromise} />
</Suspense>
</div>
);
}

When the user clicks "Refresh," startTransition prevents the UI from becoming interactive while the new promise is resolving. The Suspense boundary shows the fallback, but only if the update takes more than ~500ms (React's default transition timeout).

Key Takeaways

  • Outer boundaries show only when all inner boundaries are pending: Once any inner boundary resolves, the outer fallback is replaced.
  • Nest for independent sections: Each major section (header, sidebar, content) gets its own boundary with a custom fallback.
  • Avoid over-nesting: Logically related data should share one boundary to prevent UI fragmentation.
  • Skeleton layout matters: Make fallbacks match the shape and size of final content to prevent layout shift.
  • Control sequence with component structure: Place boundaries to enforce loading priority (e.g., header before sidebar).

Frequently Asked Questions

Can I have two boundaries at the same level?

Yes. Sibling boundaries operate independently. Each shows its fallback when its children's promises are pending:

<div>
<Suspense fallback={<Skeleton1 />}>
<Section1 />
</Suspense>
<Suspense fallback={<Skeleton2 />}>
<Section2 />
</Suspense>
</div>

Both fallbacks can show simultaneously.

What if I want one section to finish loading before another starts?

Move the second section into a component that's only rendered after the first section resolves. Use a parent component to manage this:

function TwoPhaseLoad() {
return (
<Suspense fallback={<Skeleton1 />}>
<Section1OnComplete />
</Suspense>
);
}

function Section1OnComplete() {
const data1 = use(promise1);
return (
<>
<Section1 data={data1} />
<Suspense fallback={<Skeleton2 />}>
<Section2 />
</Suspense>
</>
);
}

How deep can I nest boundaries?

There's no hard limit, but more than 3–4 levels becomes confusing. Flatten when possible by grouping related data.

Can I change the fallback after a boundary is visible?

No. Once a boundary is mounted, its fallback prop is fixed. To change fallback UI, conditionally render different boundaries or use useState inside the child component (see Progressive Enhancement pattern above).

Do nested boundaries improve performance?

Yes. Nested boundaries let React prioritize which parts of the tree to render first. By nesting, you allow React to show some content while other parts are still loading, which feels faster to users.

Further Reading