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.
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)
Users see meaningful content immediately, even before slow data loads
Improved FCP, LCP, and TTI metrics through progressive loading
Progressive rendering feels faster and more responsive to users
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.
async function DataComponent() { const data = await fetchData(); // π suspends here return <div>{data}</div> }
<Suspense fallback={<Loading />}> <DataComponent /> {/* shows <Loading /> while waiting */} </Suspense>
// Server sends HTML chunk: <div id="suspense-1"> <div>Loaded data: ...</div> </div> // React replaces fallback with real content
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.
async function StreamingPokemonCard() { const pokemon = await fetchPokemon(); // π suspends here return <PokemonCard pokemon={pokemon} />; } <Suspense fallback={<Skeleton />}> <StreamingPokemonCard /> </Suspense> // shows skeleton while streaming
Each PokΓ©mon card has its own independent Suspense boundary. All three fetch concurrently β cards stream in as they resolve, not one-after-another.
<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
Suspense boundaries can be nested for progressive enhancement. The outer shell renders instantly, then each level streams in independently.
<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>
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.
// Wrap each section independently <ErrorBoundary fallback={<ErrorFallback />}> <Suspense fallback={<Skeleton />}> <AsyncComponent /> </Suspense> </ErrorBoundary>
How the server and client collaborate to deliver progressive HTML
Copy-ready patterns for the most frequent Suspense use cases
// 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> ); }
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
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
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
// β 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
SuspenseProvider, selective hydration, and transition-aware loading
Create reusable Suspense wrappers with the SuspenseProvider pattern for consistent loading states across your application.
// 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> ); }
React's Selective Hydration works with Suspense to prioritize interactive components. Components that haven't streamed yet don't block hydration of other parts.
Use useTransition to show the current UI while new content loads in the background, avoiding jarring fallbacks during navigation.
"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> ); }
Measured improvements in Core Web Vitals from streaming
First Contentful Paint (FCP)
Users see content faster with streaming shell
Largest Contentful Paint (LCP)
Main content appears sooner with parallel loading
Time to Interactive (TTI)
Selective hydration makes pages interactive faster
Total Blocking Time (TBT)
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.
1.5m Β· 40.5kg