Next.js Streaming and Progressive Rendering
Stream content progressively with Suspense to improve perceived performance and UX.
TL;DR
- 01Use Suspense to stream content as it's ready.
- 02Show loading states while slow components render.
- 03Stream improves perceived performance and Core Web Vitals.
Tips
- 01Use streaming to show partial content quickly — users perceive the page as faster even if all data isn't ready yet.
Warnings
- 01Don't create too many Suspense boundaries — keep them at logical boundaries to avoid confusing loading states.
Suspense Boundaries
- Use Suspense to show loading states while data loads.
import { Suspense } from "react"; import SlowComponent from "./SlowComponent"; export default function Page() { return ( <Suspense fallback={<p>Loading...</p>}> <SlowComponent /> </Suspense> ); } - Content is streamed as soon as it's ready.
- Users see the page progressively instead of waiting.
- Use a
loading.tsxfile as a route-level Suspense boundary.// app/dashboard/loading.tsx export default function Loading() { return <div className="skeleton">Loading dashboard...</div>; } - Suspense boundaries catch async server components automatically in App Router.
// Any async server component inside Suspense is streamed async function SlowData() { const data = await fetchSlowAPI(); // streamed when ready return <ul>{data.map(d => <li key={d.id}>{d.name}</li>)}</ul>; }
Nested Suspense Boundaries
- Create multiple boundaries for granular control.
export default function Dashboard() { return ( <div> <Header /> <Suspense fallback={<p>Loading posts...</p>}> <Posts /> </Suspense> <Suspense fallback={<p>Loading sidebar...</p>}> <Sidebar /> </Suspense> </div> ); } - Each boundary loads independently.
- Fast components render immediately.
- Slow components show loading states separately.
- Nest boundaries to control exactly which sections block each other.
<Suspense fallback={<HeaderSkeleton />}> <Header /> <Suspense fallback={<FeedSkeleton />}> <Feed /> </Suspense> </Suspense> - Outer boundary shows while inner boundaries resolve independently.
Server Components with Streaming
- Server components naturally stream their data.
// app/page.tsx import { Suspense } from "react"; import Header from "./components/Header"; import Post from "./components/Post"; export default function Home() { return ( <> <Header /> <Suspense fallback={<p>Loading posts...</p>}> <Post /> </Suspense> </> ); } // components/Post.tsx (server component) async function Post() { const posts = await fetch("https://api/posts").then(r => r.json()); return posts.map(post => <article key={post.id}>{post.title}</article>); } - Server components fetch data directly.
- Data streams as soon as it's ready.
- Parallel data fetching inside server components speeds up streaming.
async function Page() { // Both fetches start at the same time const [user, posts] = await Promise.all([ fetchUser(), fetchPosts() ]); return <Profile user={user} posts={posts} />; } - Avoid waterfalls by fetching all needed data in parallel.
Error Boundaries While Streaming
- Wrap a streamed Suspense boundary with the nearest
error.tsxso a thrown component swaps its fallback for an error UI instead of crashing the page.// app/dashboard/error.tsx — catches errors thrown by streamed children "use client"; export default function Error({ error, reset }: { error: Error; reset: () => void }) { return ( <div> <p>Failed to load this section.</p> <button onClick={reset}>Retry</button> </div> ); } - A component that throws inside a
<Suspense>boundary already streamed to the client gets replaced in place by the nearest error boundary's UI.async function Reviews({ id }) { const reviews = await fetchReviews(id); // throws on network failure return <ul>{reviews.map(r => <li key={r.id}>{r.text}</li>)}</ul>; } // If Reviews throws after streaming starts, error.tsx output // replaces only the Reviews chunk -- sibling boundaries stay intact. - Only the failed boundary's fallback is replaced; sibling Suspense boundaries that already streamed in keep their resolved content.
- A thrown error after the initial shell has flushed swaps in via an inline script, so streamed error recovery silently no-ops if JavaScript is disabled.
- Use
try/catchinside an async Server Component to return a fallback value instead of throwing, when inline UI is better than delegating toerror.tsx.async function Reviews({ id }) { try { const reviews = await fetchReviews(id); return <ReviewList reviews={reviews} />; } catch { return <p>Reviews are unavailable right now.</p>; // no error boundary triggered } } - Place
error.tsxat the same route segment as the Suspense boundary it should protect — a parent segment's error.tsx also catches throws from streamed children further down.
Prioritizing Stream Order
- Load critical content first, then push secondary content behind its own boundary so it streams in later.
export default function Product({ id }) { return ( <div> <Suspense fallback={<p>Loading product...</p>}> <BasicProduct id={id} /> </Suspense> <Suspense fallback={<p>Loading reviews...</p>}> <Reviews id={id} /> </Suspense> <Suspense fallback={<p>Loading recommendations...</p>}> <Recommendations id={id} /> </Suspense> </div> ); } - Render above-the-fold content synchronously, outside any Suspense boundary, so it never waits on a fallback.
<main> <HeroSection /> {/* renders synchronously, no Suspense */} <Suspense fallback={<Skeleton />}> <BelowFoldContent /> {/* streams in after hero */} </Suspense> </main> - React flushes resolved boundaries in the order they finish, not the order they appear in JSX, so a slow first boundary never blocks a fast later one.
- Race a slow data source against a timeout and fall back to cached or partial data, so one flaky dependency can't stall its entire boundary.
async function withTimeout(promise, ms, fallback) { const timeout = new Promise(resolve => setTimeout(() => resolve(fallback), ms)); return Promise.race([promise, timeout]); } - Deferred, lower-priority sections streaming in after the hero improve Largest Contentful Paint without delaying it.
State Transition Updates
- Keep UI responsive during state changes.
import { useTransition } from "react"; export default function Page({ id }) { const router = useRouter(); const [isPending, startTransition] = useTransition(); function handleNavigate() { startTransition(() => { router.push(`/product/${id}`); }); } return ( <> {isPending && <p>Loading...</p>} <button onClick={handleNavigate}> View Product </button> </> ); } - useTransition keeps UI responsive during async operations.
- Show loading state without blocking interaction.
- Mark expensive state updates as non-urgent with startTransition.
const [isPending, startTransition] = useTransition(); function handleFilter(value) { setInputValue(value); // urgent: update input immediately startTransition(() => { setFilteredList(filterItems(value)); // non-urgent: can wait }); } - useDeferredValue defers a value update to keep UI responsive.
const deferredQuery = useDeferredValue(query); // deferredQuery lags behind query, preventing expensive re-renders return <Results query={deferredQuery} />;
FAQ
Streaming sends the initial HTML shell immediately, then flushes chunks as server components resolve, which improves Time to First Byte (TTFB) and Largest Contentful Paint (LCP) since the browser can start rendering and hydrating before all data is fetched.
Yes — nested Suspense boundaries let inner components show their own fallback independently of outer ones, so a fast sidebar can render while a slow feed still shows a skeleton. Each boundary resolves and hydrates as soon as its own async work completes.
loading.js creates an implicit Suspense boundary around an entire route segment and applies automatically to all navigations to that route, while explicit
Move the slow fetch inside its own async Server Component, then wrap that component in <Suspense fallback={
This happens when the async operation resolves before React flushes the boundary, but the fallback is still briefly painted. Wrap the component with startTransition on the client side for navigation updates, or use the new React 19 use() hook with a cached promise to avoid unnecessary fallback renders on re-renders.