Saved
Frontend Pattern

Async Boundary

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

Difficulty Advanced

By Den Odell

Async Boundary

Problem

Components flash between loading states inconsistently, showing disruptive skeleton screens or leaving parts of the UI frozen while data loads. Without centralized loading boundaries, every component must manage its own loading state through local state variables, creating an inconsistent user experience with spinners appearing and disappearing unpredictably across the page. Individual components don’t know about their siblings’ loading states, so users see parts of the interface materialize at different times, creating a disjointed and chaotic experience. Parent components cannot easily coordinate when to show loading indicators for groups of related components, leading to either overly granular spinners everywhere or a single page-level spinner that blocks the entire interface even when only a small section needs data.

Solution

Wrap async components in boundaries that declare what to show while loading, centralizing loading states at logical UI sections.

Define boundaries around cohesive interface regions like sidebar navigation, main content areas, or widget panels so that entire sections transition together from loading to loaded state. The boundary component intercepts loading state from its children and displays a single fallback UI until all child components are ready, then reveals all children simultaneously.

This creates predictable loading patterns where entire regions transition together rather than individual components flickering independently. Boundaries can be nested to create hierarchical loading strategies where critical content loads first while secondary content shows its own loading state independently.

Example

This example shows how to define a loading boundary that displays a spinner while an async component loads its data.

React with Suspense

import { Suspense } from 'react';

// Wrap async components in Suspense to manage loading state
function App() {
  return (
    <Suspense fallback={<Spinner />}>
      {/* This component can load data asynchronously */}
      {/* The Spinner will show until the component is ready */}
      <UserProfile userId={userId} />
    </Suspense>
  );
}

// Async component that throws promise to trigger Suspense
function UserProfile({ userId }) {
  const user = use(fetchUser(userId)); // React 19+ use() hook
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
    </div>
  );
}

Nested Boundaries for Progressive Loading

function Dashboard() {
  return (
    <div className="dashboard">
      {/* Primary content loads first */}
      <Suspense fallback={<MainContentSkeleton />}>
        <MainContent />
      </Suspense>

      {/* Sidebar loads independently */}
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />
      </Suspense>

      {/* Activity feed can load last without blocking other content */}
      <Suspense fallback={<ActivitySkeleton />}>
        <ActivityFeed />
      </Suspense>
    </div>
  );
}

Multiple Async Dependencies

function ProductPage({ productId }) {
  return (
    <Suspense fallback={<PageSkeleton />}>
      {/* All three components must load before boundary resolves */}
      <ProductDetails productId={productId} />
      <ProductReviews productId={productId} />
      <RelatedProducts productId={productId} />
    </Suspense>
  );
}

Vue 3 Suspense

<template>
  <!-- Vue 3 Suspense component -->
  <Suspense>
    <!-- Async component that may load data -->
    <template #default>
      <UserProfile :userId="userId" />
    </template>

    <!-- Fallback while loading -->
    <template #fallback>
      <Spinner />
    </template>
  </Suspense>
</template>

<script setup>
import { defineAsyncComponent } from 'vue';

const props = defineProps(['userId']);

const UserProfile = defineAsyncComponent(() =>
  import('./UserProfile.vue')
);
</script>

Svelte with Await Blocks

<script>
  export let userId;

  async function loadUserProfile(userId) {
    const response = await fetch(`/api/users/${userId}`);
    return response.json();
  }

  let userPromise = loadUserProfile(userId);
</script>

{#await userPromise}
  <!-- Loading state -->
  <Spinner />
{:then user}
  <!-- Success state -->
  <div class="profile">
    <h1>{user.name}</h1>
    <p>{user.bio}</p>
  </div>
{:catch error}
  <!-- Error state -->
  <ErrorMessage message={error.message} />
{/await}

Vanilla JavaScript with Web Components

class AsyncBoundary extends HTMLElement {
  constructor() {
    super();
    this.loading = true;
    this.attachShadow({ mode: 'open' });
  }

  async connectedCallback() {
    // Show loading fallback
    this.shadowRoot.innerHTML = '<slot name="fallback"><spinner-component></spinner-component></slot>';

    try {
      // Load async component
      const userId = this.getAttribute('user-id');
      const component = await this.loadAsyncComponent(userId);

      this.loading = false;
      this.shadowRoot.innerHTML = '';
      this.shadowRoot.appendChild(component);
    } catch (error) {
      this.shadowRoot.innerHTML = '<slot name="error"><error-message></error-message></slot>';
    }
  }

  async loadAsyncComponent(userId) {
    // Simulate async loading
    const { UserProfile } = await import('./user-profile.js');
    const component = document.createElement('user-profile');
    component.setAttribute('user-id', userId);
    return component;
  }
}

customElements.define('async-boundary', AsyncBoundary);

Usage with Web Components

<async-boundary user-id="123">
  <!-- Fallback slot shown while loading -->
  <div slot="fallback">
    <skeleton-loader></skeleton-loader>
  </div>
  
  <!-- Error slot shown if loading fails -->
  <div slot="error">
    <error-banner></error-banner>
  </div>
</async-boundary>

Streaming with Server Components

// Next.js App Router with streaming
export default function Page() {
  return (
    <div>
      {/* Static content renders immediately */}
      <Header />
      
      {/* This boundary streams from server */}
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid /> {/* Server Component fetches data */}
      </Suspense>
      
      {/* Footer renders immediately */}
      <Footer />
    </div>
  );
}

Timeout and Fallback Strategies

function PageWithTimeout() {
  const [showFallback, setShowFallback] = useState(false);

  useEffect(() => {
    // Show fallback after 200ms to avoid flash for fast loads
    const timer = setTimeout(() => setShowFallback(true), 200);
    return () => clearTimeout(timer);
  }, []);

  return (
    <Suspense fallback={showFallback ? <Spinner /> : null}>
      <SlowComponent />
    </Suspense>
  );
}

Benefits

  • Creates predictable, cohesive loading patterns across UI regions by ensuring entire sections transition together from loading to loaded states.
  • Reduces visual chaos by preventing individual components from flickering independently as they load at different rates.
  • Centralizes loading logic instead of scattering it across individual components, reducing code duplication and making loading behavior consistent.
  • Improves perceived performance by showing meaningful loading states that communicate progress rather than leaving users staring at partially rendered interfaces.
  • Simplifies component code by moving loading concerns to boundaries, allowing child components to focus on rendering data without managing loading states.
  • Enables progressive enhancement where critical content can load first while less important sections load independently with their own boundaries.
  • Supports streaming architectures where server components can send HTML progressively, filling in async boundaries as data becomes available.

Tradeoffs

  • Requires framework support like React Suspense, Vue Suspense, or custom implementation - not all frameworks provide first-class async boundary primitives, requiring manual coordination of loading states.
  • Can delay showing any content until all async components within the boundary have loaded, creating an all-or-nothing loading experience that may frustrate users if one slow component blocks an entire section.
  • May need careful boundary placement to balance granularity and cohesion - too few boundaries create coarse-grained loading that blocks large sections, while too many boundaries create visual noise with many small loading indicators.
  • Debugging loading issues becomes harder with multiple nested boundaries - determining which boundary is blocking and why requires understanding the dependency tree and async resolution order.
  • Not all components or data fetching patterns work well with boundaries - components that fetch data imperatively in effects or event handlers cannot trigger Suspense, requiring refactoring to use framework-specific data fetching patterns.
  • Boundaries that coordinate multiple async children have no inherent timeout mechanism - if one child never resolves, the boundary stays in loading state indefinitely unless timeout logic is manually implemented.
  • Error handling within boundaries requires additional patterns - if one async component fails, the entire boundary may fail unless error boundaries are combined with async boundaries to handle failures gracefully.
  • The fallback UI must be chosen carefully - overly detailed skeleton screens may be as expensive to render as the actual content, while minimal spinners may not provide enough context about what’s loading.
  • Streaming architectures with server components add complexity to boundary behavior - understanding when boundaries resolve on the server versus client and how they interact with hydration requires deep framework knowledge.
  • Some data fetching libraries implement their own loading state management that may conflict with framework-level async boundaries, requiring integration work or choosing between competing patterns.
Stay Updated

Get New Patterns
in Your Inbox

Join thousands of developers receiving regular insights on frontend architecture patterns

No spam. Unsubscribe anytime.