Streaming in Next.js lets you send UI to the browser before the entire page finishes rendering on the server. This guide covers practical patterns, real code, and production pitfalls for full-stack developers.
Streaming in Next.js is one of the most underrated performance features you can adopt today. Instead of waiting for your entire server component tree to resolve, you can send shell HTML immediately and stream in slower parts as they complete. I've seen Time to First Byte (TTFB) drop from 800ms to under 150ms on real projects. But it's not a silver bullet — sometimes it adds complexity without measurable benefit. Let me show you exactly when it shines, how to implement it, and where most teams trip up.
Why Streaming in Next.js Matters (and When to Skip It)
Streaming matters because it directly attacks perceived latency. Users see content faster, which improves Core Web Vitals — specifically Largest Contentful Paint (LCP) and First Contentful Paint (FCP). Your server sends the static shell (nav, sidebar, layout) first, then streams in the dynamic content like product lists or user dashboards.
But here's my opinion: don't stream everything. If your page is a simple blog post with one database query taking 50ms, streaming adds overhead without user-perceived benefit. The React Server Components payload and Suspense boundaries introduce complexity. Skip it for static pages, marketing sites, or any route where the slowest data fetch is under 200ms. Use it for dashboards, admin panels, and pages with multiple independent data sources.
Getting Started with Streaming in Next.js
You need Next.js 14 or 15 with the App Router. Streaming works out of the box with Server Components — you don't need a special config. Here's the minimal setup:
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserProfile } from './UserProfile';
import { ActivityFeed } from './ActivityFeed';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* This renders immediately */}
<p>Welcome back, user.</p>
{/* These stream in as they resolve */}
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}
The key is that UserProfile and ActivityFeed are async Server Components. Next.js streams each Suspense boundary independently. Here's what a streaming component looks like:
// app/dashboard/UserProfile.tsx
import { getUser } from '@/lib/api';
export default async function UserProfile() {
// This fetch delays rendering of this boundary only
const user = await getUser();
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
No special streaming API needed. The await inside the async component tells Next.js to pause this boundary and stream the rest.
Core Streaming in Next.js Concepts Every Developer Should Know
Suspense Boundaries
Suspense boundaries define what streams independently. Each boundary is its own chunk. I've found that granular boundaries (one per logical section) work better than wrapping the entire page. This lets the browser paint meaningful content while other sections load.
// Good: granular boundaries
<Suspense fallback={<TableSkeleton />}>
<OrdersTable />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<StatsCards />
</Suspense>
// Bad: one giant boundary blocks everything
<Suspense fallback={<PageSkeleton />}>
<OrdersTable />
<StatsCards />
</Suspense>
The streaming shell
Next.js sends the HTML for non-suspense content immediately. This shell includes your layout, navigation, and any static content. The streaming chunks arrive as Server Components resolve. You can verify this works by checking the Network tab — you'll see multiple response chunks, not one big HTML file.
loading.tsx files
Next.js automatically wraps your page in Suspense when you have a loading.tsx file. This is the simplest streaming pattern:
// app/feed/loading.tsx
export default function Loading() {
return <div className="animate-pulse">Loading feed...</div>;
}
The loading.tsx file acts as an automatic fallback for the entire page segment. It's perfect for quick wins, but loses the granularity of manual Suspense boundaries.
Common Streaming in Next.js Mistakes and How to Fix Them
Mistake 1: Blocking the stream with non-suspense awaits
If you put an await directly in the page component (outside any Suspense boundary), it blocks the entire stream. The shell won't render until that promise resolves.
// BAD: blocks the whole stream
export default async function Page() {
const data = await fetchData(); // waits for this
return <div>{/* everything waits */}</div>;
}
// FIX: wrap it
export default function Page() {
return (
<Suspense fallback={<Loading />}>
<AsyncSection />
</Suspense>
);
}
async function AsyncSection() {
const data = await fetchData();
return <div>{data}</div>;
}
Mistake 2: Streaming with client-side data fetching
Streaming works with Server Components. If you're using useEffect to fetch data in client components, you're not streaming — you're just doing client-side rendering. The streamed HTML arrives empty, then the client fetches. You lose the SEO and initial paint benefits.
Mistake 3: Ignoring the await in nested components
If a child component inside a Suspense boundary has its own await, it becomes part of that boundary's stream. This is fine — but if you nest Suspense inside Suspense, the inner boundary resolves first, then the outer. This can cause waterfalls. I've found it's better to keep boundaries flat and parallel rather than nested.
When Should You Use Streaming in Next.js?
Use streaming when your page has at least one of these characteristics: multiple independent data fetches that take over 200ms each, a slow database query that blocks a critical section, or a need to improve LCP on a route with heavy server-side processing. Streaming is ideal for authenticated dashboards, analytics views, e-commerce product pages with recommendations, and any page where users benefit from seeing the shell immediately.
Avoid streaming for static marketing pages, simple blog posts, or any route where the entire server render completes in under 150ms. The complexity isn't worth it there.
Streaming in Next.js in Production
Set a timeout on your streams. If a streamed component hangs, it can hold the connection open indefinitely. Use Promise.race or a timeout wrapper to fail fast:
async function fetchWithTimeout(url: string, ms = 5000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ms);
try {
const res = await fetch(url, { signal: controller.signal });
return await res.json();
} finally {
clearTimeout(timeout);
}
}
Monitor your server response times. Streaming shifts work from a single TTFB to multiple chunk arrivals. Track both TTFB and Last Contentful Paint (LCP) to ensure streaming actually helps. Use Vercel Analytics or your own logging to compare streamed vs non-streamed routes.
Cache aggressively. Streamed components that fetch static data should use unstable_cache or revalidate tags. Otherwise, every request re-fetches data, negating the performance benefits. I've seen teams stream properly but forget to cache, ending up with slower pages than before.
Test with slow networks. Use Chrome DevTools throttling to simulate 3G. Streaming shines here — you'll see the shell render at 1 second instead of waiting 5+ seconds for the full page. If you don't see this behavior, your boundaries are probably too coarse.
The one concrete takeaway: start by wrapping your slowest server component in a single Suspense boundary with a loading.tsx fallback. Measure TTFB and LCP before and after. If you see improvement, add granular boundaries for your next-slowest sections. If not, your data layer is the bottleneck — fix that before adding more streaming complexity.