Skip to main content
Saved

Async Boundary

Category Resilience

Level Advanced

Examples

React, Vue, Svelte, Native web

Define fallback UI to show while asynchronous components or data are loading.

In this article
  1. Problem
  2. Solution
  3. Example
  4. Basic Async Boundary
  5. Nested Boundaries for Progressive Loading
  6. Multiple Async Dependencies
  7. Streaming with Server Components
  8. Delayed Fallback
  9. Benefits
  10. Tradeoffs
  11. Summary
  12. See also

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>
  );
}

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

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.