C
Next.js/Rendering/Lesson 07

Streaming + Suspense — Delivering Pages in Parts, Not All at Once

35 min·theory
This chapter
4/5
TypeScript

Streaming + Suspense — Delivering Pages in Parts, Not All at Once

💡 Why Should You Learn This? — The Slowest Fetch Blocks the Entire Page

🎯 When you `await fetch()` inside a Server Component, the entire page waits until that component finishes. The header could be shown immediately, but a 5-second comment fetch forces the header to wait 5 seconds as well.
💼 Wrapping with a `` boundary loads only that section separately while the rest is shown immediately — this is 'progressive HTML streaming'.
Next.js automatically wraps `loading.tsx` in the same folder as ``. This provides folder-level loading UX.
🔗 Multiple fetches are fired in parallel (not with Promise.all) and each result is progressively displayed as it arrives — the fastest sections are shown to the user first.
📈 A core feature of React 18+ — enabling 'waterfall-free data loading'.
🏢 실무에서는
Shopping page: product info (fast) + reviews (medium) + recommended products (slow). With the old approach, the entire page waits until the recommended products fetch completes, leaving the user staring at a blank screen for 4–5 seconds. Separating the three with Suspense yields: product info at 0.3s → reviews at 1s → recommendations at 3s — each section appears as soon as it arrives. The perceived performance improves dramatically.

Suspense Boundaries · loading.tsx · Progressive Streaming

1. How Suspense Boundaries Work

tsx
import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <Header />  {/* Renders immediately */}
      <Suspense fallback={<ReviewSkeleton />}>
        <Reviews />  {/* Shows ReviewSkeleton while Reviews awaits */}
      </Suspense>
      <Suspense fallback={<RecommendSkeleton />}>
        <Recommend />  {/* Recommend loads independently */}
      </Suspense>
    </div>
  );
}

async function Reviews() {
  const reviews = await fetch('https://api/reviews').then(r => r.json());
  return <ul>{reviews.map(r => <li key={r.id}>{r.text}</li>)}</ul>;
}
  • Header is sent as HTML immediately.
  • The slots for Reviews and Recommend are filled with their fallbacks.
  • Once each component's fetch completes, its HTML is streamed in-place (even after the closing </html> tag).

2. loading.tsx — Automatic Suspense at the Folder Level

code
app/posts/
├── page.tsx
└── loading.tsx  ← The entire page.tsx is automatically wrapped in <Suspense fallback={<Loading />}>

The fastest way to handle page-level loading states. No explicit import required.

3. Explicit vs. loading.tsx

ToolWhen to Use
loading.tsxThe entire page is waiting for data — a blank page + spinner is sufficient
Direct <Suspense>Only part of the page is slow — you want the rest to show immediately

In practice, use both together: loading.tsx for the overall skeleton, and inside the page for fine-grained splitting.

4. The Visual Effect of Progressive Streaming

code
Time →
0.1s: <html><body><Header />...skeletons...</body>
0.3s:                ...Reviews arrives...
1.0s:                                 ...Recommend arrives...

User experience: 'Something loaded fast' (Header at 0.1s) → 'Reviews are in' (0.3s) → 'Recommendations too' (1s). With the old approach, users would see a blank screen for 1 second before everything appeared at once.

5. Avoiding Waterfall — Parallel Fetch + Parallel Suspense

tsx
// ❌ Serial waterfall — Recommend can't start until Reviews finishes
export default async function Page() {
  const reviews = await getReviews();
  const recommend = await getRecommend();
  return <div>{reviews.length} reviews, {recommend.length} recommendations</div>;
}

// ✅ Parallelized with Suspense — both start fetching at the same time
export default function Page() {
  return (
    <>
      <Suspense fallback={<S1 />}><Reviews /></Suspense>
      <Suspense fallback={<S2 />}><Recommend /></Suspense>
    </>
  );
}
// Reviews and Recommend are each async components — they start concurrently

6. Pairing with Error Boundaries — error.tsx

tsx
<ErrorBoundary fallback={<E />}>
  <Suspense fallback={<S />}>
    <Reviews />  {/* If it throws, ErrorBoundary catches it; if it awaits, Suspense catches it */}
  </Suspense>
</ErrorBoundary>

In the App Router, error.tsx automatically acts as an ErrorBoundary.

💻 🅰️ Without Suspense — The Slowest Fetch Blocks Everything
// ❌ Full await — Slow components block fast parts

// 📁 app/product/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
  // All three are sequential (or even if parallel with Promise.all, all three must finish to render)
  const product = await fetch(`/api/products/${params.id}`).then(r => r.json());
  const reviews = await fetch(`/api/reviews?productId=${params.id}`).then(r => r.json());
  const recommend = await fetch(`/api/recommend/${params.id}`).then(r => r.json());

  // This return only executes when all three have arrived
  // If the slowest takes 3 seconds, the user sees a blank screen for 3 seconds
  return (
    <div>
      <h1>{product.name}</h1>
      <Reviews items={reviews} />
      <Recommend items={recommend} />
    </div>
  );
}
💻 🅱️ Split with Suspense Boundaries — Show Each Part As It Arrives
// ✅ Split with Suspense boundaries — Stream each as it arrives

import { Suspense } from 'react';

// 📁 app/product/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
  // Only await fast data — header displayed immediately
  const product = await fetch(`/api/products/${params.id}`).then(r => r.json());

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.price} won</p>

      {/* Slow parts wrapped in Suspense for progressive disclosure */}
      <Suspense fallback={<ReviewSkeleton />}>
        <Reviews productId={params.id} />
      </Suspense>

      <Suspense fallback={<RecommendSkeleton />}>
        <Recommend productId={params.id} />
      </Suspense>
    </div>
  );
}

// Child components — each async
async function Reviews({ productId }: { productId: string }) {
  const reviews = await fetch(`/api/reviews?productId=${productId}`).then(r => r.json());
  return (
    <ul>
      {reviews.map((r: { id: number; text: string }) => (
        <li key={r.id}>{r.text}</li>
      ))}
    </ul>
  );
}

async function Recommend({ productId }: { productId: string }) {
  const items = await fetch(`/api/recommend/${productId}`).then(r => r.json());
  return <ul>{items.map((i: { id: number; name: string }) => <li key={i.id}>{i.name}</li>)}</ul>;
}

function ReviewSkeleton() {
  return <div className="animate-pulse">Loading reviews...</div>;
}

function RecommendSkeleton() {
  return <div className="animate-pulse">Loading recommendations...</div>;
}

// 📁 app/product/[id]/loading.tsx — On initial page entry (when header can't even be drawn)
export default function Loading() {
  return <div className="animate-pulse">Loading product information...</div>;
}

// User perceived experience:
// 0.1s: Header + skeletons displayed (partial HTML arrives)
// 0.5s: Reviews filled in (additional HTML streamed)
// 2.0s: Recommendations filled in (additional HTML streamed)
// → Blank screen time 0.1s (vs 3s with old method)

💡 💡 Streaming + Suspense: 5 Real-World Tips

1. loading.tsx covers the whole page; covers a part
If 90% of the page is waiting for data, loading.tsx is sufficient. If only a portion is slow, use for fine-grained splitting.

2. Only async Server Components are caught by Suspense
State data from a Client Component's useState is not caught by Suspense (that is the domain of useTransition / useDeferredValue).

3. An await inside Suspense automatically throws — React catches it
No need for explicit throw or try/catch. Just write an async function as-is.

4. Avoid waterfall — each component fetches its own data
If the parent fetches all data and passes it down via props, a waterfall occurs. When each component fetches its own data, React handles them in parallel.

5. Suspense boundaries pair with ErrorBoundary

If an await rejects, ErrorBoundary catches it — outside of Suspense, not inside. The App Router's error.tsx handles this automatically.

code
<ErrorBoundary fallback={<E />}>      ← error.tsx is automatic
  <Suspense fallback={<S />}>           ← loading.tsx or explicit
    <AsyncComponent />
  </Suspense>
</ErrorBoundary>

⚡ Try It Yourself — Progressive Streaming Simulation

This simulation shows how HTML is progressively rendered as each component arrives based on its load time.
✏️ JS 코드
📟 Console output
▶ Press the Run button
⚠️ Runs in a browser sandbox — only console.log() is supported; alert/fetch are not.

Check Your Understanding

In the App Router, what is the simplest approach when the entire page is loading data?
💡 Simply placing `loading.tsx` in the same folder causes Next.js to automatically wrap the entire page.tsx in `<Suspense fallback={<Loading />}>` — zero lines of import or registration code needed. When only part of a page is slow, use explicit `<Suspense>` boundaries to split it into sections. Both approaches can be used together.
Streaming + Suspense — Progressive HTML Streaming - Next.js