Next.js & the Network
Next.js & the Network

Streaming & Suspense: the response as a stream

Lesson · ~40 min
After this unit you can
  • Explain how a streamed App Router response is delivered: the shell and Suspense fallbacks first, each boundary's content later in the same response
  • Place loading.tsx or <Suspense> for a page, and predict which await blocks the whole route and which blocks one section
  • Predict the status code when notFound() or redirect() runs after streaming has started, and move the check before anything streams
  • Explain Partial Prerendering under Cache Components: the prerendered shell, dynamic holes, and the blocking-route error for uncached data outside <Suspense>
  • Spot the layers that buffer a stream (nginx, compression, curl, the platform) and verify chunk timing with a stream reader

Read

Simple
1 / 2

The page arrives in pieces — one response, many chunks

Flag

Without streaming, a server builds the whole HTML document and only then sends it, so the page is as slow as its slowest query. With streaming the server sends the parts that are ready now and the rest later, in the same response, using chunked transfer encoding.

Here is that at the HTTP level, from a small node:http server that writes three pieces 800 ms apart, read with fetch and a stream reader:

/plain: status 200, transfer-encoding=chunked, content-length=null
  +  31ms  <p>shell</p>
  + 827ms  <p>slow part</p>
  +1628ms  <p>end</p>

No Content-Length (the server doesn't know it yet), and the first bytes arrive in 31 ms although the response takes 1.6 s. The browser starts painting from the first chunk.

The App Router does this for you, and <Suspense> decides where the cuts go:

// app/dashboard/page.tsx
import { Suspense } from "react";

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading revenue…</p>}>
        <Revenue />          {/* async: ~200 ms */}
      </Suspense>
      <Suspense fallback={<p>Loading orders…</p>}>
        <RecentOrders />     {/* async: ~1 s */}
      </Suspense>
    </div>
  );
}

The first chunk carries the <h1> and both fallbacks: the static shell. When Revenue resolves, React streams its HTML plus a tiny inline script that swaps it in for the fallback. A second later the orders do the same. Each boundary resolves on its own: the slow one never holds up the fast one, and none of them holds up the shell.

That's the whole mental model:

  • everything outside any pending boundary → first chunk;
  • each <Suspense> boundary → its fallback first, its real content when its async work finishes;
  • the user sees the page fill in section by section, and can interact with what has hydrated.

Medium
1 / 2

Which await blocks the route — push it down to where the value is used

Flag

An await blocks everything rendered after it in the same component, and everything below that component. So the higher an await sits, the more of the page waits for it.

// ❌ app/products/[id]/page.tsx — one slow call holds the whole page
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const product = await getProduct(id);                 // 80 ms
  const recommendations = await getRecommendations(id); // 2.5 s
  return (
    <>
      <ProductDetails product={product} />
      <Recommendations items={recommendations} />
    </>
  );
}

Nothing can stream until the 2.5-second call returns, because the JSX that would contain the <Suspense> boundaries hasn't been produced yet. Move the slow await into the component that needs it and give that component a boundary:

// ✅ the details render now; only the recommendations wait
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const product = await getProduct(id);
  return (
    <>
      <ProductDetails product={product} />
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations productId={id} />
      </Suspense>
    </>
  );
}

async function Recommendations({ productId }: { productId: string }) {
  const items = await getRecommendations(productId); // suspends only this boundary
  return <ul>{items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;
}

That is how it plays out without Cache Components: the page still waits 80 ms for the product, then streams. With cacheComponents: true the build enforces the same idea more strictly: the remaining top-level work must be cacheable too (getProduct under use cache, params covered by generateStaticParams), or it also moves behind a boundary. The next reading covers why.

The Streaming guide applies the same rule to request data in a layout. Don't await cookies() at the top; start it and hand the promise to the component that reads it:

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = cookies(); // started, not awaited
  return (
    <div>
      <Nav>
        <Suspense fallback={<p>Loading user…</p>}>
          <UserMenu cookiePromise={cookieStore} />
        </Suspense>
      </Nav>
      {children}
    </div>
  );
}

<Nav> and {children} stay in the shell; only UserMenu waits.

Sibling or nested? Sibling boundaries resolve independently, each in whatever order its data arrives. Nested boundaries reveal in order: the inner fallback can only appear once the outer content has rendered. Use siblings for unrelated panels and nesting for "details, then reviews below them".

A Client Component can wait too. Start the fetch in a Server Component without awaiting it, pass the promise as a prop, and read it in the Client Component with React's use(promise) inside a <Suspense>. The fallback ships in the shell; the data streams in when the promise resolves.

Complex
1 / 2

The status code goes out with the first byte

Flag

An HTTP response starts with its status line and headers. Once the first chunk of a streamed body has been sent, they're on the wire and can't be taken back. Node shows it plainly:

res.write("<p>shell</p>");   // status + headers are sent with this first chunk
await sleep(100);
res.statusCode = 404;        // no effect: the client already has 200
res.setHeader("X-Late", "1"); // throws ERR_HTTP_HEADERS_SENT
/status: status 200, x-late=null
  +   2ms  <p>shell</p>
  + 103ms  <p>notFound() happened here; setHeader threw: ERR_HTTP_HEADERS_SENT</p>

Next.js lives under the same rule. Streaming starts when a Suspense fallback renders (including one from loading.tsx) or a component suspends under a boundary. After that:

  • notFound() can't send a 404. Next streams the not-found UI and injects <meta name="robots" content="noindex"> so search engines skip the URL, but the status stays 200. Some crawlers report it as a "soft 404".
  • redirect() can't send a 307. It becomes a client-side redirect: Next injects a <meta http-equiv="refresh"> tag into the stream instead.
  • An error thrown mid-stream is caught by the nearest error.tsx, which replaces that section; the rest of the page stays, and the status is still 200.

The trap is how easily a page ends up streaming before its check:

app/posts/[slug]/
  loading.tsx   ← makes the whole page a Suspense boundary
  page.tsx      ← const post = await getPost(slug); if (!post) notFound();

loading.tsx wraps the page, so the moment getPost suspends, the fallback streams with a 200, and a missing post becomes a soft 404 for every crawler and every uptime check.

When the status code matters (missing resources, auth redirects, analytics), decide it before anything streams: a fast existence check at the top of the page with no boundary above it, or earlier still in proxy.ts (rewrite to a not-found route, or return a 404 response) or a next.config redirect. Both run before rendering, while the status line is still unwritten.

Quiz

A product page awaits getProduct() (80 ms) and then getRecommendations() (2.5 s) at the top of page.tsx, before returning both sections. How do you get the product on screen while recommendations load?

Flag

app/dashboard/layout.tsx does const user = await getUser((await cookies()).get("session")?.value). The folder also has loading.tsx. Without Cache Components, what happens when someone navigates to /dashboard?

Flag

Three panels resolve at 200 ms, 1 s and 3 s. They're wrapped in one <Suspense> boundary. When does the user see the 200 ms panel?

Flag

app/posts/[slug]/ has loading.tsx, and page.tsx does const post = await getPost(slug); if (!post) notFound();. What status does a request for a missing slug get?

Flag

With cacheComponents: true, a page has a static <header>, a <BlogPosts> component using use cache + cacheLife("hours"), and <Suspense fallback={<p>Loading…</p>}><UserPrefs /></Suspense> where UserPrefs reads cookies(). What's in the prerendered shell?

Flag

Under Cache Components, a Server Component renders Math.random() and is wrapped in <Suspense>. What happens at next build?

Flag

Streaming works on next start locally. Behind your nginx in production, each page arrives in one piece after the slowest boundary. First suspect?

Flag

Fill in the blank

Name the file that wraps this folder's page in a Suspense boundary.

Flag
app/dashboard/.tsx

Stream the orders panel on its own, with a placeholder until it's ready.

Flag
< fallback={<p>Loading orders…</p>}>
  <RecentOrders />
</Suspense>

Read a promise started in a Server Component, inside a Client Component under <Suspense>.

Flag
"use client";
import { use } from "react";

export function StatsChart({ dataPromise }: { dataPromise: Promise<Stats> }) {
  const stats = (dataPromise);
  return <Chart data={stats} />;
}

Under Cache Components, make this component produce a new ID per request instead of one frozen at build.

Flag
import { connection } from "next/server";

async function RequestId() {
  await ();
  return <p>Request {crypto.randomUUID()}</p>;
}

Tell nginx in front of next start not to buffer streamed responses.

Flag
async headers() {
  return [{ source: "/:path*{/}?", headers: [{ key: "", value: "no" }] }];
}

Predict the output

// node:http, streaming by hand
res.setHeader("Content-Type", "text/html");
res.write("<p>shell</p>");
await sleep(100);
res.statusCode = 404;
try { res.setHeader("X-Late", "1"); } catch (e) { console.log(e.code); }
res.end("<p>not found</p>");
Flag
// node:http + zlib, no flush between writes
res.setHeader("Content-Encoding", "gzip");
const gz = zlib.createGzip();
gz.pipe(res);
gz.write("<p>shell</p>");
await sleep(800);
gz.write("<p>late part</p>");
await sleep(800);
gz.end("<p>end</p>");

// a client timestamps each chunk it receives
Flag
export default function Page() {
  return (
    <Suspense fallback={<FullPageSkeleton />}>
      <Header />            {/* static */}
      <Feed />              {/* awaits an uncached API, 2 s */}
    </Suspense>
  );
}
Flag

Further reading