Type something to search...
Next.js Streaming

Next.js Streaming

Streaming is the mechanism underneath nearly every other article in this series' caching and rendering coverage — Cache Components, instant navigation, PPR, the Suspense-boundary patterns everywhere — and it deserves its own dedicated treatment because it's genuinely the foundational primitive the rest of that machinery builds on. This article covers what streaming actually is at the HTTP level, how the App Router uses it by default, and the practical consequences for Web Vitals and infrastructure that follow directly from it.

What streaming actually solves

Traditional server-side rendering has one hard constraint: the server has to produce the entire HTML document before sending any of it. One slow database query, one slow API call, blocks the whole response — even the parts of the page that had nothing to do with that slow query.

Streaming, mechanically, uses HTTP chunked transfer encoding to send parts of a response as they become ready, rather than waiting for everything. The browser starts rendering the HTML it's already received while the server is still generating the rest. This matters most for exactly the shape of page most real apps actually have: fast static content (headers, navigation, layout chrome) mixed with slower dynamic content (personalized data, recommendations, anything hitting a live data source). The static parts can prerender and paint instantly from a CDN; the dynamic parts stream in behind them as they resolve.

React's server renderer produces HTML in chunks that align directly with <Suspense> boundaries in your component tree, and Next.js wires this into the App Router with no additional configuration required to get the basic mechanism working.

Two streams, working together

On an initial page load, two genuinely distinct things are happening simultaneously, and it's worth keeping them conceptually separate because they solve different problems.

The HTML stream is what the user actually sees. React's server renderer sends static parts (layouts, navigation, Suspense fallbacks) immediately. When a <Suspense> boundary's content resolves — an async Server Component finishing its data fetch, say — React streams the completed HTML for it, along with two small inline <script> tags: one that swaps the fallback DOM node for the real content, and one carrying the component payload data React will need to hydrate it later. Critically, the browser executes that swap instantly, without waiting for your JavaScript bundle to finish loading or hydration to complete at all.

The component payload is the serialized component tree React actually uses to hydrate the page and handle subsequent client-side updates. On the initial load, it's embedded directly in that same HTML stream. On a client-side navigation afterward, only the component payload gets fetched (via an rsc: 1 request header) — no HTML at all crosses the wire for that navigation, since React already has everything it needs to update the existing tree in place.

The static shell is the name for everything that renders before any async work resolves — your layouts, navigation, and whatever fallback UI your <Suspense> boundaries define. It ships immediately, giving the user something concrete to look at and interact with while the genuinely dynamic content is still streaming in behind it. With Cache Components specifically, this shell gets prerendered at build time and served instantly from the edge, rather than computed fresh per request.

Each <Suspense> boundary is its own independent streaming point — components inside separate boundaries resolve and stream in on their own schedules, entirely without blocking each other.

Page-level streaming: the simplest entry point

Dropping a loading.js file next to a page.js automatically wraps that page's content in a <Suspense> boundary, using your loading component as its fallback — no manual <Suspense> JSX required at all:

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 w-48 bg-gray-200 rounded mb-4" />
      <div className="h-4 w-full bg-gray-200 rounded mb-2" />
    </div>
  );
}

Behind the scenes, this file nests inside the layout and wraps the page in a Suspense boundary automatically: the layout renders immediately as part of the static shell, the loading skeleton appears instantly as the fallback, and once the page component actually finishes, its real HTML replaces the skeleton. This is the right tool specifically when there's genuinely nothing meaningful to show until the page's data resolves — a full-page skeleton is a reasonable, honest fallback in that specific case, not a compromise.

Granular streaming: placing <Suspense> yourself

For finer control than "the whole page is one fallback," place <Suspense> boundaries explicitly around specific sections, so the static shell can include more real content rather than a blanket skeleton covering everything.

Sibling boundaries stream independently, in whatever order finishes first

// app/dashboard/page.tsx
import { Suspense } from "react";
import { Revenue } from "./revenue";
import { RecentOrders } from "./recent-orders";
import { Recommendations } from "./recommendations";

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <div className="grid grid-cols-2 gap-4">
        <Suspense fallback={<p>Loading revenue...</p>}>
          <Revenue />
        </Suspense>
        <Suspense fallback={<p>Loading orders...</p>}>
          <RecentOrders />
        </Suspense>
      </div>
      <Suspense fallback={<p>Loading recommendations...</p>}>
        <Recommendations />
      </Suspense>
    </div>
  );
}

If Revenue resolves in 200ms, RecentOrders in a second, and Recommendations in three seconds, the user genuinely sees each section appear the moment its own data is ready — not blocked waiting on whichever of the three happens to be slowest.

Nested boundaries create a layered, progressive reveal

// app/product/[id]/page.tsx
import { Suspense } from "react";
import { ProductDetails } from "./product-details";
import { Reviews } from "./reviews";

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  return (
    <div>
      <h1>Product</h1>
      <Suspense fallback={<p>Loading product details...</p>}>
        <ProductDetails id={id} />
        <Suspense fallback={<p>Loading reviews...</p>}>
          <Reviews productId={id} />
        </Suspense>
      </Suspense>
    </div>
  );
}

The outer fallback shows until ProductDetails resolves; only then does the inner boundary become visible at all, showing its own "Loading reviews..." until Reviews resolves in turn. This produces a genuinely progressive reveal — details first, reviews after — rather than everything appearing simultaneously the instant the slowest piece finishes.

The single most important discipline: push dynamic access down

This is worth internalizing as the core skill for maximizing what streams instantly: defer reading params, searchParams, cookies(), headers(), or any data fetch to the specific component that actually needs it. Awaiting any of these at the top of a layout or page makes everything below that point dynamic, unable to be part of the prerendered static shell at all.

// app/dashboard/layout.tsx
import { Suspense } from "react";
import { Nav } from "./nav";
import { UserMenu } from "./user-menu";
import { cookies } from "next/headers";

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const cookieStore = cookies(); // started, but deliberately NOT awaited here

  return (
    <div>
      <Nav>
        <Suspense fallback={<p>Loading user...</p>}>
          <UserMenu cookiePromise={cookieStore} />
        </Suspense>
      </Nav>
      {children}
    </div>
  );
}

Because nothing in the layout itself is awaited, <Nav> and {children} render as part of the static shell — only <UserMenu> actually suspends, resolving the cookie promise inside its own boundary. Had the layout instead called await cookies() at its top level, the entire layout and everything nested under it would lose eligibility for the static shell, dragged into dynamic rendering by one unnecessary top-level await.

The identical principle applies to params and searchParams — pass the promise down rather than destructuring it at the page level, and let the component that actually needs the resolved value suspend on it inside its own boundary. You can even unwrap it inline with .then() if you'd rather the child component receive a plain value than a promise, without losing the deferred-resolution benefit:

<Suspense fallback={<p>Loading products...</p>}>
  {params.then(({ category }) => (
    <ProductGrid category={category} />
  ))}
</Suspense>

Choosing between loading.js and <Suspense>

loading.js<Suspense>
ScopeEntire pageAny specific component
SetupDrop in a fileWrap explicitly in JSX
Prefetch behaviorPrefetched as an instant fallbackNot prefetched by default
Best forPages where nothing renders meaningfully without dataMost pages, for granular control

Prefer explicit <Suspense> close to the actual dynamic access, as the default instinct. Worth knowing precisely what happens if you don't place one deliberately: when the prerenderer hits dynamic work with no nearby boundary, it walks up the tree looking for the nearest one. A loading.js sitting high in the tree is a valid boundary the framework will happily use — but that means the entire page falls back to one full-page skeleton rather than streaming granularly, silently, with no error to tell you it happened. If you find yourself surprised that a page shows one big loading skeleton instead of the granular streaming you expected, this — an implicit fallback to a distant loading.js — is very often why.

Errors mid-stream don't get a fresh status code

If a component throws after streaming has already begun, the nearest error.js boundary catches it and renders in place of just that failed section — the rest of the page stays intact. But because the 200 OK status was already sent with the very first chunk, it cannot retroactively change to a 4xx or 5xx — the error is handled entirely within the already-streamed HTML, not at the HTTP layer. This constraint runs deep enough to warrant its own section below.

Streaming raw data, not just UI

The same "start now, resolve later" pattern extends beyond page components — start a fetch in a Server Component, pass the unresolved promise as a prop, and only the component that actually calls React's use() to read it needs a <Suspense> wrapper:

// app/dashboard/stats-chart.tsx
"use client";

import { use } from "react";

export function StatsChart({ dataPromise }: { dataPromise: Promise<Stats> }) {
  const stats = use(dataPromise);
  return <div>{/* render chart with stats */}</div>;
}

And Route Handlers can stream raw responses directly via the Web Streams API, entirely outside React's own rendering — useful for Server-Sent Events, generating large files on the fly, or any response you'd rather deliver progressively:

// app/api/stream/route.ts
export async function GET() {
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 10; i++) {
        controller.enqueue(encoder.encode(`Chunk ${i + 1}\n`));
        await new Promise((resolve) => setTimeout(resolve, 200));
      }
      controller.close();
    },
  });
  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

A genuinely useful, easy-to-miss variant: FileHandle.readableWebStream() streams a file's contents directly, without ever loading the whole thing into memory — worth knowing about specifically for large file downloads where buffering the entire file server-side first would be wasteful.

The direct effect on Web Vitals

Streaming isn't just an architectural nicety — it maps to concrete, measurable metrics.

TTFB and FCP. Without streaming, TTFB equals your single slowest query, full stop. With it, the server sends the static shell the moment it's ready, so TTFB collapses to roughly "how long it takes to render your layouts and fallbacks" — and FCP becomes genuinely decoupled from your data-fetching time entirely.

LCP is more nuanced. If your actual LCP element — a hero image, a main heading — sits inside a Suspense boundary, it literally cannot paint until that boundary swaps in, meaning it's now gated on that specific boundary's resolution time, not your overall response time. To keep LCP fast: keep LCP elements outside or above any Suspense boundary so they're part of the static shell by construction; use next/image's preload prop for an LCP image specifically, which injects a <link rel="preload"> so the browser starts fetching it from the very first chunk (this controls when it's fetched, not when it paints — an image still inside a boundary still waits for the swap regardless); and for non-image LCP elements, keep them outside boundaries entirely for the same reason.

CLS comes from a fallback being replaced by differently-sized real content, causing a reflow. Design skeleton fallbacks to actually match the dimensions of what they represent, and use fixed or min-height containers around Suspense boundaries so the layout doesn't shift when content lands.

INP benefits from what streaming enables structurally: selective hydration. Each <Suspense> boundary is its own hydration unit — React hydrates independently as content streams in, prioritizing whatever the user is actually interacting with right now. Without boundaries, React hydrates the entire page in one blocking pass; with them, hydration breaks into smaller tasks that yield back to the browser, keeping the main thread responsive throughout.

Early resource discovery is a smaller but genuinely useful side effect: the static shell's <link> and <script> tags arrive in the very first HTML chunk, so the browser starts fetching CSS, JS, and fonts immediately — during server "think time," rather than only after the full response finally lands.

The HTTP contract you can't undo

This is the constraint everything above ultimately answers to: once streaming begins, response headers — including the status code — have already gone out. You cannot change them after the fact.

If notFound() fires mid-stream, Next.js can't retroactively flip a sent 200 to a 404 — instead it injects <meta name="robots" content="noindex"> so search engines at least don't index the page. A mid-stream redirect() similarly becomes a client-side redirect rather than a genuine HTTP redirect header, since the header window has already closed.

If you need a real HTTP status code for an error case, the fix is structural: call notFound() before any await or <Suspense> boundary in the function, not after:

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const exists = await checkSlugExists(slug); // fast existence check
  if (!exists) notFound(); // genuine 404 — nothing has streamed yet

  return (
    <Suspense fallback={<p>Loading post...</p>}>
      <PostContent slug={slug} />
    </Suspense>
  );
}

Proxy and next.config.js redirects both run before the page renders at all, so they're also safe ground for genuine HTTP-level status codes and redirects, unaffected by this constraint.

Bots get special treatment. HTML-limited crawlers (detected by user agent) need metadata present in the initial <head>, so Next.js waits for generateMetadata to resolve before streaming anything to them at all — a fundamentally different code path than what a real browser gets. Worth remembering if your prerendered shell depends on data only available at build time: a person gets that shell without re-running the logic that produced it, but an HTML-limited bot re-renders dynamically, so a page that works fine for a human visitor can fail specifically for a crawler if it relies on something unavailable in the request-time environment.

What quietly defeats streaming in production

The HTML can be generated progressively server-side and still arrive at the browser all at once, if anything between the two buffers the response. Every layer is worth checking individually.

Reverse proxies like nginx buffer by default — disable it with X-Accel-Buffering: no. CDNs vary widely in streaming support; check your specific provider's documentation rather than assuming. Serverless platforms aren't uniform either — AWS Lambda specifically requires response streaming mode to be explicitly enabled, since it isn't the default; Vercel supports it natively with no extra configuration. Compression (gzip, Brotli) can buffer internally to compress efficiently, adding latency before the first visible chunk. And clients buffer too — Safari/WebKit specifically holds back streaming responses until 1024 bytes have arrived, though real apps' layouts/styles/scripts comfortably exceed that threshold in practice, so it mostly only bites minimal demos or tiny Route Handler responses.

Actually verifying it's working

Chrome DevTools' Network tab is the first check — a long "Content Download" phase paired with an early "Time to First Byte" confirms genuine streaming rather than one delayed blob. For a more precise look, a small script reading the response as a raw stream (more reliable than curl, which has its own buffering quirks) will show timestamped chunks arriving independently as each Suspense boundary resolves — versus, tellingly, one single burst if you add a bot user agent to the same request, since bots get the full-render-then-send behavior described above rather than genuine incremental delivery.

Platform support

Deployment optionStreaming supported
Node.js serverYes
Docker containerYes
Static exportNo
AdaptersPlatform-specific

Static export's "No" here isn't a limitation to work around — it's a direct consequence of having no live server process at all to stream from in the first place.

Key Takeaways

ConceptWhat it means
The triggerAsync work, non-deterministic output, or runtime data in your own code
What ships firstThe static shell — everything above the nearest Suspense boundary
The core disciplinePush dynamic data access down to the component that needs it, wrap that in <Suspense>
HTTP status codesFixed the moment streaming starts — call notFound()/redirect() before any await/Suspense for a real status code
Biggest Web Vitals winSelective hydration (INP) and shell-first paint (FCP/TTFB)
Common infra pitfallA buffering proxy, CDN, or serverless platform silently erasing the benefit

The two decisions this entire mechanism reduces to are the same two decisions repeated across every other rendering article in this series: what to cache (to grow the static shell), and where to place your Suspense boundaries (to control exactly what streams, and when). Everything else — the Web Vitals implications, the HTTP contract, the infrastructure checklist — follows as a direct, mechanical consequence of those two choices.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper