Server Component Streaming: Step-by-Step
Streaming is one of the most powerful capabilities of React Server Components. Instead of waiting for all data to load before rendering, the server sends HTML and data to the browser incrementally. The browser begins rendering and showing content to the user while the server is still fetching remaining data. This technique, combined with React Suspense, dramatically improves perceived performance and Time to First Byte.
This article explains how streaming works, how to use Suspense boundaries to define streaming chunks, and shows you real patterns for progressive rendering that delight users with instant feedback.
What Is Streaming?
Streaming means the server sends the response to the browser in chunks, not all at once. With React Server Components and Suspense, the server can send complete HTML for parts of the page that are ready while asynchronously fetching other parts.
Without streaming, your server would fetch all data, render all components, and send the complete HTML in one response. If a slow database query blocks a sidebar component, the entire page is delayed.
With streaming, the server:
- Renders the fast parts of the page immediately (header, navigation, layout).
- Sends that HTML to the browser right away.
- The browser starts rendering and painting content to the screen.
- Meanwhile, the server continues fetching data for slow components.
- As slow components finish, the server sends them down, and the browser updates the page.
This results in faster Time to First Contentful Paint (FCP) and a better user experience. Measured across production Next.js applications in 2025, streaming reduces FCP by 40–70% in data-heavy pages (Vercel, 2025).
Using Suspense Boundaries
React Suspense is the mechanism that defines streaming boundaries. A Suspense component wraps a component tree and displays a fallback while the tree is still loading.
// app/page.tsx — server component with Suspense boundaries
import { Suspense } from 'react';
import { Header } from './header';
import { MainContent } from './main-content';
import { Sidebar } from './sidebar';
export default function HomePage() {
return (
<div className="page">
<Header /> {/* Rendered immediately, no await */}
<div className="container">
<Suspense fallback={<div className="loading">Loading posts...</div>}>
<MainContent /> {/* Async component; streams when ready */}
</Suspense>
<Suspense fallback={<div className="loading">Loading sidebar...</div>}>
<Sidebar /> {/* Async component; streams independently */}
</Suspense>
</div>
</div>
);
}
// components/main-content.tsx — async server component
async function MainContent() {
// Simulated slow database query
await new Promise(resolve => setTimeout(resolve, 2000));
const posts = await db.posts.findAll();
return (
<div className="posts">
<h1>Recent Posts</h1>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
// components/sidebar.tsx — async server component
async function Sidebar() {
const recommendations = await db.recommendations.getLatest();
return (
<aside className="sidebar">
<h2>Recommended</h2>
<ul>
{recommendations.map(rec => (
<li key={rec.id}>{rec.title}</li>
))}
</ul>
</aside>
);
}
The Header renders immediately and is sent to the browser. MainContent and Sidebar both await async operations. The server does not wait for them to finish. Instead, it sends the Header, a placeholder for MainContent, a placeholder for Sidebar, and streams the components as they complete.
How Streaming Works Under the Hood
When a Suspense boundary is encountered in a server component, Next.js:
- Renders the component tree synchronously, catching any promises (from async components).
- Sends the completed HTML and a placeholder for the suspended component.
- Continues rendering the async component on the server.
- When the async component resolves, sends its HTML as a separate chunk.
- The browser updates the DOM, replacing the placeholder with the real content.
The RSC Payload is split across multiple chunks. The browser receives chunks over time and updates the page incrementally without a full page reload.
// This flow illustrates how a request is streamed:
// 1. Initial chunk (sent immediately):
// <html><body><header>...</header>
// <div class="posts">Loading posts...</div>
// <aside class="sidebar">Loading sidebar...</aside></body></html>
// 2. After 1 second (MainContent resolves):
// <script>
// // Instructions to replace the "Loading posts..." div with actual posts
// </script>
// 3. After 1.5 seconds (Sidebar resolves):
// <script>
// // Instructions to replace the "Loading sidebar..." div with actual sidebar
// </script>
From the user's perspective, the page is interactive and showing content within milliseconds. They don't stare at a blank screen waiting for the entire payload.
Streaming Strategy: Where to Place Suspense Boundaries
The placement of Suspense boundaries determines your streaming strategy. Place boundaries strategically to balance fast feedback and data grouping.
Strategy 1: Suspense at the layout level (fine-grained boundaries)
Wrap each major section—header, sidebar, main content, footer—in its own Suspense boundary. This gives the fastest feedback for each independent section but sends more chunks.
export default function Layout() {
return (
<div>
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<Suspense fallback={<MainSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</div>
);
}
Strategy 2: Suspense at the feature level (coarse-grained boundaries)
Group related components—a card with image, title, and metadata—into one Suspense boundary. Fewer boundaries mean fewer chunks but potentially longer waits for related data.
export default function HomePage() {
return (
<div>
<Suspense fallback={<ArticleListSkeleton count={10} />}>
<ArticleList /> {/* Fetches posts, authors, and metadata together */}
</Suspense>
</div>
);
}
Strategy 3: Hybrid (recommended for most apps)
Use coarse-grained boundaries for sections that fetch data together, and fine-grained boundaries for independent sections. This minimizes streaming overhead while keeping critical content visible quickly.
export default function HomePage() {
return (
<div className="page">
<Header /> {/* Fast; no suspense needed */}
<Suspense fallback={<MainSkeleton />}>
<MainContent /> {/* Data-heavy; own boundary */}
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* Independent data; own boundary */}
</Suspense>
</div>
);
}
Skeleton Screens and Placeholders
The fallback prop of Suspense should show a skeleton screen—a placeholder with the same layout as the real content but without data. This keeps the layout stable and gives users something to look at while loading.
function ArticleListSkeleton() {
return (
<div className="articles">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="article-skeleton">
<div className="skeleton-title" style={{ width: '80%' }} />
<div className="skeleton-text" style={{ width: '100%' }} />
<div className="skeleton-text" style={{ width: '90%' }} />
</div>
))}
</div>
);
}
// CSS for skeleton effect
// .skeleton-title {
// height: 24px;
// background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
// background-size: 200% 100%;
// animation: shimmer 2s infinite;
// }
Streaming Waterfall Pattern: When to Avoid Suspense
While streaming is powerful, be aware of the waterfall pattern: if Component B depends on data from Component A, and both are in separate Suspense boundaries, the server will wait for A to complete before starting to fetch B's data. This negates the streaming benefit.
// ❌ Waterfall: Sidebar waits for MainContent data
export default function Page() {
return (
<div>
<Suspense fallback={<MainSkeleton />}>
<MainContent /> {/* Fetches post data */}
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar dependsOn={post} /> {/* Depends on post from MainContent */}
</Suspense>
</div>
);
}
Solution: Fetch data in parallel outside the components, or restructure so both components can fetch their data concurrently.
// ✅ Parallel: both fetch independently
async function MainContent() {
const post = await db.posts.findById(id);
return <div>{post.title}</div>;
}
async function Sidebar() {
const recommendations = await db.recommendations.getLatest();
return <aside>{recommendations}</aside>;
}
export default function Page() {
return (
<div>
<Suspense fallback={<MainSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</div>
);
}
Key Takeaways
- Streaming sends HTML incrementally to the browser, improving Time to First Byte and perceived performance.
- Suspense boundaries define chunks: the server sends content as components finish loading.
- Place Suspense boundaries at feature or section boundaries; avoid waterfall patterns where one component depends on another's data.
- Skeleton screens as fallbacks maintain layout stability and give users visual feedback.
- Streaming can reduce FCP by 40–70% in data-heavy applications compared to waiting for all data.
Frequently Asked Questions
Does streaming work without Suspense?
Streaming is a transport mechanism. Without Suspense boundaries, Next.js will still stream, but without explicit boundaries, the browser waits for more data before updating. Suspense boundaries tell Next.js where to split the response into chunks.
Can I use streaming with static generation (SSG)?
Streaming is primarily for server-side rendering (SSR) and ISR (Incremental Static Regeneration). With static generation, the entire page is pre-built, so there's no streaming. However, you can use Suspense with ISR to fetch and update data incrementally.
How long can a component wait in Suspense before the user sees a timeout?
There's no built-in timeout for Suspense. If a component never resolves (infinite async operation), the browser will never see it. Use the browser timeout (typically 30–60 seconds) or set your own timeout in the async function.
Does streaming increase server CPU usage?
Streaming can increase server CPU slightly because the server holds connections longer and renders components on-demand. However, it typically improves overall throughput by allowing the browser to start work earlier, reducing the perceived latency.
Can I combine streaming with client-side data fetching (useEffect)?
Yes. Server components can stream initial data, and client components can fetch additional data with useEffect or React Query. This pattern is useful for interactive dashboards where the server provides scaffolding and the client loads details on demand.