Home/Learn/Next.js/Streaming and Suspense

Streaming and Suspense

Advanced
Data & Caching

Send the shell immediately and let slow parts arrive as they resolve. One slow query stops holding the entire page hostage.

Overview

Server rendering has one structural weakness: the response cannot start until everything on the page is ready, so the slowest query decides the time to first byte. Streaming removes that. Next sends the HTML it already has, leaves a placeholder where a Suspense boundary sits, and pushes the real content down the same connection when it resolves. The user sees structure in a hundred milliseconds and the slow panel fills in a second later — the same total time, a completely different experience.

Boundaries Decide the Experience

Where you place Suspense is the design decision.

Put the await inside the boundary, not before it
// No boundary — the whole page waits for the slowest query
export default async function Page() {
  const [problem, stats, comments] = await Promise.all([...])   // 900ms
  return <>…</>                                  // nothing until then
}

// Boundaries — the shell is instant, each part streams
export default async function Page({ params }) {
  const problem = await getProblem(params.slug)   // fast, 40ms
  return (
    <>
      <ProblemHeader problem={problem} />         {/* immediate */}

      <Suspense fallback={<StatsSkeleton />}>
        <Stats slug={params.slug} />              {/* 300ms, streams */}
      </Suspense>

      <Suspense fallback={<CommentsSkeleton />}>
        <Comments slug={params.slug} />           {/* 900ms, streams */}
      </Suspense>
    </>
  )
}

// The awaited component must be INSIDE the boundary. This does not
// stream — the await happens before any JSX is returned:
const comments = await getComments()
return <Suspense fallback={...}><List items={comments} /></Suspense>

// loading.tsx is the same mechanism at segment level: it wraps the
// whole page in one boundary.

Passing Promises Down

Start every fetch at once, await them in separate boundaries.

Start early, await inside, or use() on the client
// Kick both off immediately, and let each resolve in its own place
export default function Page({ params }) {
  const statsPromise = getStats(params.slug)         // no await
  const commentsPromise = getComments(params.slug)   // no await

  return (
    <>
      <Suspense fallback={<StatsSkeleton />}>
        <Stats promise={statsPromise} />
      </Suspense>
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments promise={commentsPromise} />
      </Suspense>
    </>
  )
}

async function Stats({ promise }) {
  const stats = await promise
  return <StatsPanel {...stats} />
}

// Both requests are in flight from the first line — no waterfall —
// and each renders as soon as its own data lands.

// React 19 adds use(), which unwraps a promise in a CLIENT component:
'use client'
import { use } from 'react'
function Comments({ promise }) {
  const comments = use(promise)     // suspends until resolved
  return <List items={comments} />
}
// This is the promised follow-up to the React track: it lets a server
// component start a fetch and a client component consume it.

What Streaming Costs

The constraints, and where it does not work.

Decide status and cookies before the stream starts
// 1. Status codes and headers are already sent. Once streaming has
//    begun you cannot return a 404 or set a cookie — so anything that
//    decides the response must happen BEFORE the first boundary.
//    An auth check belongs above the stream, not inside it.

// 2. Some hosts and proxies buffer responses, which silently defeats
//    streaming. Verify on the real platform, not just locally.

// 3. Too many boundaries look like a broken page — five skeletons
//    popping in at different times is worse than one honest wait.
//    Group related content into one boundary.

// 4. A skeleton must match the final layout or you have traded a
//    wait for a layout shift, which is worse.

// Where it pays: a dashboard with independent panels, a product page
// where reviews are slow, any page with one slow third-party call.

// Where it does not: a page that is fast anyway, or one where the
// content only makes sense complete.

// Measure it honestly — TTFB should drop sharply while the total
// load time stays about the same. That trade is the entire point.

Key Points to Remember

  • 1Streaming sends the shell immediately and pushes slow content down the same response as it resolves
  • 2The await must be inside the Suspense boundary, or the page waits before rendering anything
  • 3Start fetches without awaiting and pass the promises down to avoid a waterfall between boundaries
  • 4use() lets a client component consume a promise started on the server
  • 5Once streaming starts the status and headers are sent, so auth checks and redirects must happen before the first boundary

Interview Questions

Sign in to ask Aria
1

What problem does streaming solve, and what does it not improve?

Medium
2

Why does awaiting data before returning a Suspense boundary defeat streaming?

Hard
3

Why must an authentication check happen before the first Suspense boundary?

Hard

Ask Aria about Streaming and Suspense

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…