Progressive Streaming
Wrap slow async components in <Suspense fallback={...}>. Next.js streams the fallback immediately and replaces it with real content as it resolves — no full-page loading spinner needed.
Streaming with Suspense
You've seen loading.tsx and Suspense boundaries in Lesson 105. Now it's
time to go deeper — to understand exactly what streaming is at the network level,
why it makes such a dramatic difference to perceived performance, and how to architect
pages that stream optimally. This is one of the most powerful features in Next.js
and one that most developers barely scratch the surface of.
1. What Streaming Actually Is
Traditional server rendering works like a restaurant that won't let you sit down until every dish for your entire table is ready. You wait at the door, starving, while the kitchen finishes the last item. Only then do you get everything at once.
Streaming is the opposite — it's like a restaurant that brings each dish out the moment it's ready. Your starter arrives while the main course is still cooking. You're eating immediately instead of waiting.
At the HTTP level, streaming means the server sends HTML in chunks over a single connection instead of waiting to send everything at once. The browser receives and renders each chunk immediately as it arrives.
Here's what happens without streaming:
- User clicks a link — browser waits
- Server fetches all data (slowest fetch determines total wait)
- Server renders the full HTML
- Server sends the entire HTML document at once
- Browser renders — user finally sees something
Here's what happens with streaming:
- User clicks a link — browser waits briefly
- Server immediately sends the HTML shell (layout, nav, static content)
- Browser renders what it has — user sees content almost instantly
- As each async component resolves, the server sends another chunk
- Browser slots each chunk into the right place in the DOM
The total time to render everything is the same — but the time to first meaningful content drops dramatically. Users perceive the app as much faster even when the underlying data fetching takes the same time.
2. How Next.js Implements Streaming
Next.js uses React's built-in Suspense system to implement streaming. When React encounters an async component that's still waiting for data, it:
- Renders the nearest Suspense boundary's
fallbackin its place - Continues rendering everything else that's ready
- Sends the current HTML to the browser
- When the async component resolves, React renders it and sends the new HTML chunk
- A small inline
<script>tag in the chunk tells the browser where to insert it
This all happens over a single HTTP connection using HTTP chunked transfer encoding — a standard feature of HTTP/1.1 that every browser and server supports.
3. The Anatomy of a Streaming Page
Let's build a real e-commerce product page that demonstrates streaming perfectly — a page where different sections have very different data loading speeds:
// app/product/[id]/page.tsx
import { Suspense } from "react";
import ProductInfo from "./_components/ProductInfo";
import ProductReviews from "./_components/ProductReviews";
import RecommendedProducts from "./_components/RecommendedProducts";
import AddToCartButton from "./_components/AddToCartButton";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<main className="max-w-6xl mx-auto p-6">
{/* Fast — product info is cached, loads in ~50ms */}
<Suspense fallback={<ProductInfoSkeleton />}>
<ProductInfo id={id} />
</Suspense>
{/* Medium — add to cart needs inventory check, ~200ms */}
<Suspense fallback={<ButtonSkeleton />}>
<AddToCartButton productId={id} />
</Suspense>
{/* Slow — reviews require aggregation query, ~800ms */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={id} />
</Suspense>
{/* Slowest — ML recommendations, ~1200ms */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendedProducts productId={id} />
</Suspense>
</main>
);
}
Here's the timeline of what the user experiences:
| Time | What the user sees |
|---|---|
| 0ms | HTML shell arrives — page layout visible, all skeletons showing |
| ~50ms | Product name, images, and price replace their skeleton |
| ~200ms | Add to Cart button appears with real inventory status |
| ~800ms | Reviews load in — star ratings and review text visible |
| ~1200ms | Recommended products appear at the bottom |
Without streaming, the user would wait 1200ms staring at nothing. With streaming, they start reading product details at 50ms and can add to cart at 200ms — well before the slowest section finishes loading.
4. Writing Async Components for Streaming
Each streamable section is its own async Server Component that owns its data fetching:
// app/product/[id]/_components/ProductInfo.tsx
import { db } from "@/lib/db";
import Image from "next/image";
export default async function ProductInfo({ id }: { id: string }) {
const product = await db.product.findUnique({
where: { id },
include: { images: true, category: true },
});
if (!product) return <p>Product not found.</p>;
return (
<section className="grid grid-cols-2 gap-8">
<Image
src={product.images[0].url}
alt={product.name}
width={600}
height={600}
/>
<div>
<p className="text-sm text-gray-500">{product.category.name}</p>
<h1 className="text-3xl font-bold">{product.name}</h1>
<p className="text-2xl mt-2">${product.price}</p>
<p className="mt-4 text-gray-700">{product.description}</p>
</div>
</section>
);
}
// app/product/[id]/_components/ProductReviews.tsx
import { db } from "@/lib/db";
export default async function ProductReviews({ productId }: { productId: string }) {
// Simulate a slow aggregation query
const reviews = await db.review.findMany({
where: { productId },
include: { author: true },
orderBy: { createdAt: "desc" },
});
const avgRating =
reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;
return (
<section className="mt-12">
<h2 className="text-2xl font-bold mb-4">
Reviews ({reviews.length}) — ⭐ {avgRating.toFixed(1)}
</h2>
<ul className="space-y-6">
{reviews.map((review) => (
<li key={review.id} className="border-b pb-4">
<p className="font-semibold">{review.author.name}</p>
<p className="text-yellow-500">{"⭐".repeat(review.rating)}</p>
<p className="text-gray-700 mt-1">{review.body}</p>
</li>
))}
</ul>
</section>
);
}
5. Designing Good Skeleton UIs
The skeleton is what the user sees while the real content streams in. A good skeleton:
- Matches the shape of the real content as closely as possible
- Has the same dimensions so the page doesn't jump when content arrives
- Uses animation to signal that loading is in progress
- Is fast to render — no async work, just static HTML
// Skeleton for ProductInfo
function ProductInfoSkeleton() {
return (
<section className="grid grid-cols-2 gap-8">
{/* Image skeleton */}
<div className="aspect-square bg-gray-200 rounded-lg animate-pulse" />
{/* Text skeleton */}
<div className="space-y-4">
<div className="h-4 w-24 bg-gray-200 rounded animate-pulse" />
<div className="h-9 w-3/4 bg-gray-200 rounded animate-pulse" />
<div className="h-8 w-32 bg-gray-200 rounded animate-pulse" />
<div className="space-y-2 mt-4">
<div className="h-4 w-full bg-gray-200 rounded animate-pulse" />
<div className="h-4 w-full bg-gray-200 rounded animate-pulse" />
<div className="h-4 w-2/3 bg-gray-200 rounded animate-pulse" />
</div>
</div>
</section>
);
}
The skeleton mirrors the two-column grid of the real ProductInfo
component — same aspect ratio for the image, same text widths for the heading
and description. When the real content arrives, the layout shift is imperceptible.
6. Nested Suspense Boundaries
Suspense boundaries nest — a boundary inside another boundary is completely independent. The outer boundary doesn't wait for the inner one:
// app/dashboard/page.tsx
import { Suspense } from "react";
export default function DashboardPage() {
return (
<div>
<Suspense fallback={<HeaderSkeleton />}>
<DashboardHeader /> {/* Resolves in ~100ms */}
{/* This nested boundary is independent — doesn't block DashboardHeader */}
<Suspense fallback={<NotificationsSkeleton />}>
<Notifications /> {/* Resolves in ~600ms */}
</Suspense>
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
<MainContent /> {/* Resolves in ~400ms */}
</Suspense>
</div>
);
}
Timeline:
- 0ms: HTML shell + all skeletons
- ~100ms:
DashboardHeaderrenders — but the outer Suspense shows its fallback until all its children resolve - ~400ms:
MainContentreplaces its skeleton independently - ~600ms:
Notificationsresolves — now the outer Suspense fully resolves too
Important: An outer Suspense boundary doesn't resolve until
all of its children — including nested boundaries — have resolved. So the outer
skeleton stays visible until Notifications resolves at 600ms,
even though DashboardHeader was ready at 100ms. If you want
DashboardHeader to appear at 100ms, it needs its own Suspense
boundary at the same level, not nested inside another.
7. Streaming with Error Handling
What happens if an async component inside a Suspense boundary throws an error?
The nearest error.tsx takes over — but only for that boundary,
not the entire page. Other Suspense boundaries that already resolved keep
their content:
// app/product/[id]/page.tsx
import { Suspense } from "react";
export default async function ProductPage({ params }) {
const { id } = await params;
return (
<main>
{/* If ProductInfo throws, only this section shows an error */}
<Suspense fallback={<ProductInfoSkeleton />}>
<ProductInfo id={id} />
</Suspense>
{/* This section is completely unaffected by ProductInfo errors */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={id} />
</Suspense>
</main>
);
}
Combined with a co-located error.tsx, each section can fail and
recover independently. A broken recommendations widget doesn't take down
the product page — the user can still see the product and add it to their cart.
8. Streaming in Layouts vs Pages
An important constraint: layouts are not streamed. A layout must fully render before Next.js starts streaming the page content. This means:
- Keep layouts fast — no slow data fetching in layouts
- If a layout has a slow component, move it to the page level inside a Suspense boundary
- The layout is the "shell" that appears instantly — it should be lightweight
// ❌ Bad — slow fetch in layout blocks the entire page from streaming
export default async function DashboardLayout({ children }) {
const slowData = await fetchSlowData(); // 800ms — delays everything
return (
<div>
<Sidebar data={slowData} />
{children}
</div>
);
}
// ✅ Better — move slow data into the page, keep layout fast
export default function DashboardLayout({ children }) {
return (
<div>
<Sidebar /> {/* Sidebar fetches its own fast data internally */}
{children} {/* Page handles its own slow data with Suspense */}
</div>
);
}
9. useFormStatus and Streaming Forms
Client Components inside a streamed page work exactly as expected — they hydrate
when their chunk arrives in the browser. You can even use React's
useFormStatus hook inside forms that submit to Server Actions to
show pending states:
// components/SubmitButton.tsx
"use client";
import { useFormStatus } from "react-dom";
export default function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className={pending ? "opacity-50 cursor-not-allowed" : ""}
>
{pending ? "Submitting..." : "Submit"}
</button>
);
}
// Used inside a Server Component form
import SubmitButton from "@/components/SubmitButton";
import { createReview } from "@/app/actions";
export default function ReviewForm({ productId }: { productId: string }) {
return (
<form action={createReview}>
<input type="hidden" name="productId" value={productId} />
<textarea name="body" placeholder="Write your review..." />
<SubmitButton />
</form>
);
}
useFormStatus must be inside a component that is a child
of the <form> — it can't be used in the same component that
renders the form itself.
10. Common Gotchas
-
Putting slow fetches directly in the page without Suspense.
An
awaitat the top ofpage.tsx— outside of any Suspense boundary — blocks the entire page from streaming. Every millisecond that await takes is a millisecond the user waits for a blank screen. Wrap slow components in Suspense boundaries instead. -
Expecting Suspense to work with Client Component data fetching.
Suspense streaming in Next.js works with async Server Components. Client Components
using
useEffectto fetch data do NOT stream — they render a loading state client-side after hydration. This is another reason to fetch in Server Components. - Forgetting that outer Suspense waits for all children. If you wrap multiple async components in a single Suspense boundary, the fallback stays visible until the slowest one resolves. Use separate boundaries for independent sections.
-
Streaming on buffering proxies. Some reverse proxies and CDNs
buffer responses before forwarding them, defeating streaming entirely. On Vercel
this works out of the box. For self-hosted setups, ensure your proxy doesn't
buffer — check for
X-Accel-Buffering: noin Nginx or equivalent settings in your proxy. - Heavy skeletons slowing down the initial response. If your skeleton component is itself slow to render (complex SVGs, many DOM nodes), it adds latency to the initial HTML. Keep skeletons simple — plain divs with Tailwind classes render near-instantly.
Key Takeaways
- Streaming sends HTML in chunks over a single HTTP connection — users see fast content immediately while slow content catches up.
- Wrap slow async Server Components in
<Suspense fallback={...}>to make them stream independently. - All Suspense boundaries on a page load in parallel — the slowest one doesn't block the others.
- An outer Suspense boundary waits for all its children — use separate peer boundaries for truly independent sections.
- Layouts are not streamed — keep them fast and move slow data fetching into pages and Suspense boundaries.
- Good skeletons match the shape of the real content to prevent layout shifts when content arrives.
- Streaming requires a non-buffering server or proxy — works out of the box on Vercel.
Next up: Lesson 205 — Server Actions & Forms. You'll learn how to mutate data on the server directly from forms, handle validation, show pending states, and build fully progressive-enhanced forms that work even without JavaScript.