⚑

Streaming & Suspense

Progressive rendering with error boundaries in React Server Components

Streaming and Suspense are fundamental patterns in React Server Components that enable progressive rendering. Instead of waiting for all data to load before showing anything, you can send HTML to the client in chunks as it becomes ready.

What is Streaming? 🌊

How HTML is chunked, sent progressively, and rendered on the client

Streaming Server Rendering (SSR) allows you to break down your page's HTML into smaller chunks and progressively send those chunks from the server to the client. This enables parts of your page to be displayed sooner, without waiting for all data to load on the server.

❌ Blocking SSR (old)

βœ… Streaming SSR (RSC)

πŸš€

Faster First Paint (FP)

Users see meaningful content immediately, even before slow data loads

πŸ“Š

Better Core Web Vitals

Improved FCP, LCP, and TTI metrics through progressive loading

✨

Perceived Performance

Progressive rendering feels faster and more responsive to users

How Suspense Works 🎯

React's declarative mechanism for async loading states

Suspenseis React's mechanism for declaratively specifying loading states for asynchronous operations. When a component suspends (awaits async data), React shows a fallback UI until the component is ready.

1
Component Suspends
async function DataComponent() {
  const data = await fetchData();  // πŸ›‘ suspends here
  return <div>{data}</div>
}
2
React Shows Fallback
<Suspense fallback={<Loading  />}>
  <DataComponent />  {/* shows <Loading /> while waiting */}
</Suspense>
3
Component Streams In
// Server sends HTML chunk:
<div id="suspense-1">
  <div>Loaded data: ...</div>
</div>

// React replaces fallback with real content

Key Characteristics:

  • β†’Declarative: Describe what to show while loading, not when
  • β†’Composable: Boundaries nest for granular control
  • β†’Non-blocking: Other parts of the page keep rendering
  • β†’Server-integrated: Works seamlessly with SSR and streaming
DEMO 1

Basic Suspense Pattern

The PokΓ©mon card below simulates a ~2 second data fetch. With Suspense, the page renders immediately and shows a skeleton while the server streams the component in.

basic-suspense.tsx
async function StreamingPokemonCard() {
  const pokemon = await fetchPokemon(); // πŸ›‘ suspends here
  return <PokemonCard pokemon={pokemon} />;
}

<Suspense fallback={<Skeleton />}>
  <StreamingPokemonCard />
</Suspense> // shows skeleton while streaming
DEMO 2

Parallel Loading with Independent Boundaries

Each PokΓ©mon card has its own independent Suspense boundary. All three fetch concurrently β€” cards stream in as they resolve, not one-after-another.

parallel-suspense.tsx
<div className="grid grid-cols-3">
  // βœ… All 3 start fetching simultaneously
  <Suspense fallback={<Skeleton />}>
    <Pikachu /> // streams at ~1s
  </Suspense>
  <Suspense fallback={<Skeleton />}>
    <Charizard /> // streams at ~2.5s
  </Suspense>
  <Suspense fallback={<Skeleton />}>
    <Mewtwo />    // streams at ~4.5s
  </Suspense>
</div>

\u23f1 Total time = max(1s, 2.5s, 4.5s) = ~4.5s, not 1 + 2.5 + 4.5 = 8s

DEMO 3

Nested Suspense Boundaries

Suspense boundaries can be nested for progressive enhancement. The outer shell renders instantly, then each level streams in independently.

nested-suspense.tsx
<Dashboard />  // renders instantly (static shell)
  <Suspense fallback={<HeaderSkeleton />}>
    <DashboardHeader />  // streams at ~1s
  </Suspense>
  <Suspense fallback={<WidgetSkeleton />}>
    <Widget1 />       // streams at ~2.5s
    <Widget2 />       // streams at ~4s (nested)
  </Suspense>
Dashboard Container
This static shell renders instantly \u26a1
DEMO 4

Error Boundaries + Suspense

Combining Error Boundaries with Suspense handles both loading and error states gracefully. When a component fails, only that isolated section shows an error β€” the rest of the page is unaffected.

error-boundary.tsx
// Wrap each section independently
<ErrorBoundary fallback={<ErrorFallback   />}>
  <Suspense fallback={<Skeleton       />}>
    <AsyncComponent />
  </Suspense>
</ErrorBoundary>
Success path β€” component loads normally
Error path β€” 404 from API, caught by boundary

Streaming Architecture πŸ—οΈ

How the server and client collaborate to deliver progressive HTML

Complete Streaming Flow
βœ“ Best Practices
  • β€’Granular Boundaries: Place Suspense at component level, not page level
  • β€’Meaningful Fallbacks: Match loading skeleton to actual content structure
  • β€’Error Handling: Always wrap Suspense in Error Boundaries
  • β€’Critical First: Load above-the-fold content before below-the-fold
  • β€’Parallel Loading: Use multiple boundaries for concurrent data fetching
  • β€’Avoid Over-Suspending: Too many boundaries cause layout shift
βœ— Anti-Patterns
  • β€’Single Page Boundary: Wrapping entire page defeats streaming purpose
  • β€’Waterfall Fetching: Nested components that wait sequentially
  • β€’Client-Side Suspense: Using Suspense for client interactions (use state)
  • β€’Suspense Without Errors: Not handling async failures gracefully
  • β€’Blocking Data: Fetching data before rendering starts
  • β€’Generic Loaders: Using same loading UI for all content types

Common Patterns πŸ“

Copy-ready patterns for the most frequent Suspense use cases

Pattern 1 β€” Basic Server Component with Suspense
profile-page.tsx
// Server Component (async by default)
async function UserProfile({ userId }: { userId: string }) {
  const user = await fetchUser(userId);  // No API route needed
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// Parent wraps with Suspense β€” skeleton shown while streaming
export default function ProfilePage() {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userId="123" />
    </Suspense>
  );
}
Pattern 2 β€” Parallel Loading with Multiple Boundaries
dashboard.tsx
export default function Dashboard() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {/* All three fetch in parallel */}
      <Suspense fallback={<WidgetSkeleton />}>
        <RevenueWidget />   {/* fetches revenue */}
      </Suspense>

      <Suspense fallback={<WidgetSkeleton />}>
        <UsersWidget />     {/* fetches users  */}
      </Suspense>

      <Suspense fallback={<WidgetSkeleton />}>
        <OrdersWidget />    {/* fetches orders */}
      </Suspense>
    </div>
  );
}

// βœ… Total time = max(revenue, users, orders)
// ❌ NOT  revenue + users + orders
Pattern 3 β€” Error Boundary + Suspense Composition
product-page.tsx
import { ErrorBoundary } from "@/components/ErrorBoundary";

export default function ProductPage() {
  return (
    <ErrorBoundary fallback={<ProductError />}>
      <Suspense fallback={<ProductSkeleton />}>
        <ProductDetails />
      </Suspense>
    </ErrorBoundary>
  );
}

// Handles three states:
// β€’ Loading  β†’ shows ProductSkeleton
// β€’ Error    β†’ shows ProductError (if fetch fails)
// β€’ Success  β†’ shows ProductDetails
Pattern 4 β€” Nested Suspense for Progressive Enhancement
article-page.tsx
export default function ArticlePage() {
  return (
    <div>
      <Suspense fallback={<HeaderSkeleton />}>
        <ArticleHeader />          {/* critical, fast */}
      </Suspense>

      <Suspense fallback={<ContentSkeleton />}>
        <ArticleContent />         {/* medium */}

        <Suspense fallback={<CommentsSkeleton />}>
          <ArticleComments />      {/* non-critical, slow */}
        </Suspense>
      </Suspense>
    </div>
  );
}
// Stream order: Header β†’ Content β†’ Comments
⚠️Anti-Pattern β€” Waterfall Loading
waterfall.tsx
// ❌ BAD: Sequential loading (waterfall)
async function ParentComponent() {
  const data1 = await fetchData1();    // waits 2s

  return (
    <div>
      <Child1 data={data1} />
      <Suspense fallback={<Loading />}>
        <Child2 />                      {/* starts AFTER parent loads! */}
      </Suspense>
    </div>
  );
}
// Total time: 2s + 3s = 5s


// βœ… GOOD: Parallel loading
export default function ParentComponent() {
  return (
    <div>
      <Suspense fallback={<Loading />}>
        <Child1 />    {/* starts immediately */}
      </Suspense>
      <Suspense fallback={<Loading />}>
        <Child2 />    {/* starts immediately */}
      </Suspense>
    </div>
  );
}
// Total time: max(2s, 3s) = 3s

Advanced Concepts πŸŽ“

SuspenseProvider, selective hydration, and transition-aware loading

Streaming with SuspenseProvider

Create reusable Suspense wrappers with the SuspenseProvider pattern for consistent loading states across your application.

SuspenseProvider.tsx
// providers/SuspenseProvider.tsx
import { Suspense, type ReactNode } from "react";

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

export function SuspenseProvider({
  children,
  fallback = <DefaultLoader />,
}: Props) {
  return <Suspense fallback={fallback}>{children}</Suspense>;
}

// Usage β€” drop-in replacement for raw <Suspense>
import { SuspenseProvider } from "@/providers/SuspenseProvider";

export default function Page() {
  return (
    <SuspenseProvider fallback={<CustomLoader />}>
      <AsyncContent />
    </SuspenseProvider>
  );
}
Selective Hydration

React's Selective Hydration works with Suspense to prioritize interactive components. Components that haven't streamed yet don't block hydration of other parts.

How it works:

  1. Server sends static HTML shell with Suspense placeholders
  2. Client immediately hydrates available (non-suspended) content
  3. When user interacts, React prioritizes that component
  4. Suspended components hydrate as they stream in from server
  5. Page becomes progressively interactive
Transition API with Suspense

Use useTransition to show the current UI while new content loads in the background, avoiding jarring fallbacks during navigation.

product-list.tsx
"use client";
import { useTransition, useState } from "react";

export function ProductList() {
  const [isPending, startTransition] = useTransition();
  const [category, setCategory] = useState("all");

  return (
    <div>
      <button
        onClick={() => {
          startTransition(() => {
            setCategory("new"); // doesn't block the UI
          });
        }}
        className={isPending ? "opacity-50" : ""}
      >
        New Products
      </button>

      <Suspense fallback={<Loading />}>
        <Products category={category} />
      </Suspense>
    </div>
  );
}

Performance Impact πŸ“Š

Measured improvements in Core Web Vitals from streaming

First Contentful Paint (FCP)

-40%average improvement

Users see content faster with streaming shell

Largest Contentful Paint (LCP)

-30%average improvement

Main content appears sooner with parallel loading

Time to Interactive (TTI)

-50%average improvement

Selective hydration makes pages interactive faster

Total Blocking Time (TBT)

-60%average improvement

Progressive hydration reduces main thread blocking

πŸ“ˆ Real-world example

A dashboard with 5 widgets: Without streaming, users wait for the slowest widget (5s). With streaming + parallel loading, users see the page shell immediately and widgets appear as they load β€” the page is fully interactive in ~5s but usable after ~1s.

πŸ’‘ Key Takeaways
  • β†’Streaming breaks HTML into chunks and sends them progressively, improving perceived performance
  • β†’Suspense declaratively marks async boundaries with loading fallbacks β€” no manual loading state needed
  • β†’Error Boundaries catch rendering errors in async components and prevent full page crashes
  • β†’Parallel loading with multiple boundaries reduces total load time to the slowest single request
  • β†’Avoid waterfalls by fetching data at the component level, not in parent-child chains
  • β†’Selective hydration makes pages interactive progressively as components stream in
  • β†’Granular boundaries at component level provide better UX than page-level boundaries
  • β†’Error Boundaries + Suspense should always be combined in production for resilient apps