Skip to main content
Saved

Pattern Resilience Advanced

Async Boundary

Async boundaries wrap a group of components and handle their loading and error states, so the components inside only have to render data instead of managing each request.

Examples
React, Vue, Svelte and native web
Read
3 min

The problem

A dashboard often loads in pieces. The header appears first, then a spinner where the chart should be, then the sidebar pops in, then another spinner, and then the chart finally shows up while a third section is still loading. The layout keeps jumping around until everything has arrived.

The pattern

An async boundary wraps a group of related components and coordinates their loading states, so the group only appears once everything inside it is ready.

Problem

When the loading states aren’t coordinated, each component handles its own, so one widget might show a skeleton while another is just empty. Users can’t tell what’s loaded and what’s still coming, and the whole page feels janky.

Going the other way, with a single spinner for the whole page that stays until even the least important widget has loaded, isn’t much better. Users could be reading the main content, but they’re looking at a spinner because one small widget in the corner is slow.

Solution

You’d usually put boundaries around logical sections such as the main content and the sidebar. The boundary shows a single fallback until all of its children are ready, and then they all appear together, so the section doesn’t flicker or fill in piece by piece.

If you nest boundaries, different parts of the page can load at different speeds. You can give the important content its own boundary so it appears first, and give the less important sections their own boundaries so they load independently. Within each section, everything still appears together.

Example

In every framework, you wrap the async content in a boundary with 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

With a separate boundary around each section, the content in a section can appear as soon as it’s ready, instead of waiting for the whole page.

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

When you put related components in one boundary, they 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, so the static content can appear straight away.

// 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

If you delay the fallback, the spinner doesn’t flash on screen when the content loads quickly. The timer runs inside the fallback component, because React only mounts the fallback when the boundary suspends, so the delay starts at that moment instead of 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, so it doesn’t flicker as each piece arrives.
  • Loading states are predictable, because each section switches from loading to loaded all at once, so users can tell which parts of the page are ready.
  • Components don’t have to manage their own loading state, so they just render data and the boundary coordinates the loading.
  • Progressive loading is easy to set up, because you can give secondary content its own boundary so the important content appears first, and server streaming fills in the rest as the data arrives.

Tradeoffs

  • You need framework support, such as React Suspense, Vue Suspense, or Svelte’s await blocks, and without it, you have to build the boundary yourself.
  • One slow child delays the whole boundary, so if one of four widgets takes five seconds to load, users wait five seconds to see any of them.
  • It can be tricky to choose where your boundaries go, because with too few, large parts of the page wait on each other, and with too many, there are spinners everywhere.
  • Nested boundaries are harder to debug, because you need to keep track of which boundary is waiting for which data.
  • Some ways of fetching data don’t work with boundaries. Fetching in useEffect won’t trigger Suspense, for example, so you might need to refactor.
  • There’s no built-in timeout, so a child that never resolves keeps the boundary waiting forever unless you add your own timeout logic.
  • Error handling needs extra work, because unless you combine async boundaries with error boundaries, one failure can take down the whole section.

Summary

With an async boundary, you define the loading and error states for a group of components in one place, so the components themselves only render data, and the loading states are predictable. Combined with error boundaries, async boundaries show a loading indicator while data is fetched and handle failures without breaking the rest of the page.

See also

Next in Resilience Error Boundary An error boundary catches JavaScript errors in part of your component tree and shows a fallback UI, so the rest of the app keeps working.

Newsletter

A monthly email
from Den Odell.

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

No spam. Unsubscribe anytime.