Skip to content
C Codeloom
Next.js

Next.js Streaming and Suspense Boundaries

Learn how the App Router streams HTML over the wire and how Suspense boundaries control what users see while data loads.

·4 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • How Next.js streams server-rendered HTML
  • Where to place Suspense boundaries
  • How loading.tsx files work
  • Parallel data fetching patterns
  • Avoiding waterfalls

Prerequisites

  • Comfortable with HTML and JavaScript

What and Why

Traditional server rendering blocks the response until every database call finishes. The user stares at a blank page even though most of the layout could have been sent immediately. Streaming flips that around. Next.js sends the static parts of the page as soon as they are ready and fills in slow parts later. Suspense boundaries tell React which pieces can be skipped temporarily.

Mental Model

Imagine the rendered tree as a set of nested boxes. Each box marked with <Suspense> becomes a chunk that can be flushed separately. If the data inside that box is still loading, React sends the fallback first. When the data arrives, it streams the actual content and the browser swaps it into place. There is no client-side JavaScript juggling required.

Server                          Browser
send shell + nav        ->     paint shell
send sidebar            ->     paint sidebar
(feed still loading)
send feed when ready    ->     swap fallback for content
close stream            ->     hydrate
Streaming with Suspense

Hands-on Example

A simple dashboard with a fast header and a slow feed.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import Feed from './Feed';
import FeedSkeleton from './FeedSkeleton';

export default function Dashboard() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<FeedSkeleton />}>
        <Feed />
      </Suspense>
    </main>
  );
}
// app/dashboard/Feed.tsx
export default async function Feed() {
  const posts = await fetch('https://api.example.com/feed', {
    next: { revalidate: 30 },
  }).then(r => r.json());

  return (
    <ul>
      {posts.map(p => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

The header renders immediately. The feed shows the skeleton until the fetch resolves, then streams in. No useEffect, no client state.

Next.js also recognizes a special loading.tsx file as a route-level Suspense boundary.

// app/dashboard/loading.tsx
export default function Loading() {
  return <p>Loading dashboard...</p>;
}

When the user navigates to /dashboard, the framework wraps the page in a Suspense boundary using this component as the fallback. The previous page stays visible until the shell is ready.

For parallel data fetching, kick off the promises before awaiting them.

export default async function Profile({ id }: { id: string }) {
  const userPromise = fetch(`/api/users/${id}`).then(r => r.json());
  const ordersPromise = fetch(`/api/orders?user=${id}`).then(r => r.json());

  const [user, orders] = await Promise.all([userPromise, ordersPromise]);
  return <ProfileView user={user} orders={orders} />;
}

Common Pitfalls

Placing a single Suspense boundary at the root of the page defeats the point. The whole page then waits for the slowest child. Push boundaries down to the actual slow component so the rest of the layout can render right away.

Calling client-side fetch from a useEffect inside a server component is a common mistake during migrations. Streaming only helps when the data fetch happens on the server. If you must keep a client component, move the fetch into a server parent and pass results in as props.

Headers and cookies need care. Reading them inside a streamed component is fine, but mutating them inside an action that runs after the response has started is not. Schedule mutations in the same request that begins the stream.

Practical Tips

Use multiple Suspense boundaries around independent slow regions so they can stream in parallel. Each boundary can resolve as soon as its own data is ready.

Avoid waterfalls by lifting Promise.all to the highest shared component. If two requests can run together, they should.

Test your streaming behavior with throttled network conditions. Tools like the Chrome network panel let you simulate slow servers, which makes it obvious whether your boundaries are placed well.

If you use a CDN, confirm it does not buffer the response. Some legacy proxies hold back chunked transfer encoding until the full body arrives. Vercel and most modern CDNs stream by default.

Wrap-up

Streaming changes server rendering from an all-or-nothing operation into a series of small flushes. Suspense boundaries are the labels that mark where those flushes can happen. Drop boundaries around any region that does its own data fetching and you get fast first paint plus progressive enhancement without any extra plumbing.