Async Boundary
Define fallback UI to show while asynchronous components or data are loading.
In this article
Async Boundary
Problem
A typical dashboard loads in pieces. First the header appears, then a spinner where the chart should be, then the sidebar pops in, then another spinner, then the chart finally shows up while a third section is still loading, and the layout keeps jumping until everything has arrived.
Without coordinated loading states, every component is on its own. One widget shows a skeleton, another shows a spinner, a third just sits there empty. Users can’t tell what’s loaded and what’s still coming, and the whole page feels janky.
The opposite approach, a single page-level spinner that blocks everything until even the least important widget loads, isn’t much better. Users could be reading the main content, but they’re staring at a spinner because some corner widget is slow.
Solution
A boundary wraps a group of related components and coordinates their loading states, showing the group only once everything inside it is ready.
Boundaries usually go around logical sections such as the main content, the sidebar, and a widget panel. Everything inside a boundary shows a single fallback until all its children are ready, then they appear together, so the section doesn’t flicker or fill in piece by piece.
Nesting boundaries lets different parts of the page load at different speeds. Important content gets its own boundary so it appears first, and less critical sections get their own boundaries and load independently. Critical content appears quickly while secondary content takes longer, but within each section, things still appear together.
Example
In every framework, you wrap the async content, provide a fallback, and let the framework coordinate the rest.
Basic Async Boundary
import { Suspense, use } from 'react';
// use() needs the same promise on every render, so cache one per user
const userRequests = new Map();
function getUser(userId) {
if (!userRequests.has(userId)) userRequests.set(userId, fetchUser(userId));
return userRequests.get(userId);
}
function App({ userId }) {
return (
<Suspense fallback={<Spinner />}>
<UserProfile userId={userId} />
</Suspense>
);
}
function UserProfile({ userId }) {
const user = use(getUser(userId)); // React 19+ use() hook
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
} <template>
<Suspense>
<template #default>
<UserProfile :userId="userId" />
</template>
<template #fallback>
<Spinner />
</template>
</Suspense>
</template>
<script setup>
import { defineAsyncComponent } from 'vue';
const props = defineProps(['userId']);
const UserProfile = defineAsyncComponent(() => import('./UserProfile.vue'));
</script> <script>
let { userId } = $props();
let userPromise = $derived(fetch(`/api/users/${userId}`).then(r => r.json()));
</script>
{#await userPromise}
<Spinner />
{:then user}
<div class="profile">
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
{:catch error}
<ErrorMessage message={error.message} />
{/await} class AsyncBoundary extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
async connectedCallback() {
this.shadowRoot.innerHTML = '<slot name="fallback"></slot>';
try {
const userId = this.getAttribute('user-id');
await import('./user-profile.js'); // defines <user-profile>
const el = document.createElement('user-profile');
el.setAttribute('user-id', userId);
this.shadowRoot.innerHTML = '';
this.shadowRoot.appendChild(el);
} catch (error) {
this.shadowRoot.innerHTML = '<slot name="error"></slot>';
}
}
}
customElements.define('async-boundary', AsyncBoundary); Nested Boundaries for Progressive Loading
Separate boundaries for each section let content appear independently rather than waiting for everything:
function Dashboard() {
return (
<div className="dashboard">
<Suspense fallback={<MainContentSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}
Multiple Async Dependencies
Grouping related components in one boundary makes them appear together:
// All three must load before the boundary resolves
function ProductPage({ productId }) {
return (
<Suspense fallback={<PageSkeleton />}>
<ProductDetails productId={productId} />
<ProductReviews productId={productId} />
<RelatedProducts productId={productId} />
</Suspense>
);
}
Streaming with Server Components
Server components stream HTML as data becomes available, showing static content immediately:
// Next.js App Router - static content renders immediately, async streams in
export default function Page() {
return (
<div>
<Header />
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
<Footer />
</div>
);
}
Delayed Fallback
Delaying the fallback prevents spinner flash for fast loads. The timer lives inside the fallback component, because React only mounts the fallback when the boundary suspends, so the delay starts at that moment rather than when the page first mounts:
// Avoid spinner flash for fast loads by waiting 200ms before showing it
function DelayedSpinner({ delay = 200 }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setVisible(true), delay);
return () => clearTimeout(timer);
}, [delay]);
return visible ? <Spinner /> : null;
}
function PageWithDelayedFallback() {
return (
<Suspense fallback={<DelayedSpinner />}>
<SlowComponent />
</Suspense>
);
}
Benefits
- The UI loads a whole section at a time rather than flickering piece by piece.
- Loading states are predictable, because each section switches from loading to ready as a unit, so users can tell what’s ready.
- Components don’t manage their own loading state. They just render data while boundaries handle coordination.
- Progressive loading is easy to set up: critical content appears first while secondary content loads in its own boundary, and server streaming fills in content as data arrives.
Tradeoffs
- You need framework support: React Suspense, Vue Suspense, Svelte’s await blocks. Without first-class support, you’re building it yourself.
- One slow child blocks the whole boundary. Four widgets with one taking five seconds means users wait five seconds for all four.
- Boundary placement is tricky: too few means coarse loading that blocks too much, too many means spinners everywhere.
- Debugging nested boundaries gets harder since you need to track which boundary is waiting on what.
- Not all data fetching works with boundaries. Fetching in useEffect won’t trigger Suspense, so you may need to refactor.
- No built-in timeout means a child that never resolves keeps the boundary waiting forever unless you add timeout logic.
- Error handling needs extra work: combine async boundaries with error boundaries, or one failure takes down the whole section.
Summary
Async boundaries separate synchronous rendering from asynchronous operations by defining clear loading and error states at component boundaries. Combined with error boundaries, they show loading indicators while data is fetched and handle failures without breaking the rest of the page. This declarative approach keeps async complexity out of components and makes loading states predictable.
See also
- Error Boundary — Resilience
- Loading State — Resilience
- Lazy Loading — Performance
- Suspense - React
- Suspense - Vue
- Await Blocks - Svelte
- Streaming and Suspense - Next.js