Dev Logs
/Next.js/ Chapter 8: Loading, Suspense & Streaming
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
    • Plain English Explanation
    • loading.tsx — the easy version
    • It also handles navigation
    • Manual <Suspense> — the useful version
    • Suspense boundaries also parallelize
    • Designing good fallbacks
    • How streaming actually works
    • Streaming + parallel routes
    • Streaming with after()
    • Common Pitfalls
    • . Awaiting slow data outside a boundary
    • . One boundary around everything
    • . Too many boundaries
    • . Sequential awaits inside one component
    • . Layout shift from mismatched skeletons
    • . Fallbacks that flash
    • . Expecting loading.tsx to help a Client Component fetch
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Fix the streaming
    • Problem 2: Predict the timeline
    • Problem 3: Choose the boundaries
    • Problem 4: Spot the anti-pattern
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

⏳ Chapter 8: Loading, Suspense & Streaming

How Next.js sends a page in pieces so users see something immediately, and where to draw the boundaries.

📖 Plain English Explanation

Imagine ordering at a restaurant. Two possible experiences:

Restaurant A waits until every dish for your table is ready, then brings everything at once. You stare at an empty table for twenty minutes.

Restaurant B brings bread immediately, then the starters as they're ready, then mains. You're eating within two minutes.

Traditional server rendering is Restaurant A. The server runs every query, assembles the complete HTML, and only then sends anything. If one query takes three seconds, the user stares at a blank page for three seconds.

Streaming is Restaurant B. The server sends the parts of the page it can render immediately — the header, the nav, the layout, skeleton placeholders — and then streams in each slow section as its data arrives. The user sees a real page in 200ms and watches it fill in.

React's mechanism for this is <Suspense>. Next.js wires it into the router with loading.tsx and lets you place boundaries manually wherever you want finer control.

📄 loading.tsx — the easy version

Drop a loading.tsx next to a page.tsx and Next.js automatically wraps the page in a Suspense boundary using that file as the fallback.

app/dashboard/
├── layout.tsx
├── loading.tsx    ← shown while page.tsx is loading
└── page.tsx
tsx
// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="space-y-4">
      <div className="h-8 w-48 animate-pulse rounded bg-slate-200" />
      <div className="h-64 animate-pulse rounded bg-slate-200" />
    </div>
  )
}
jsx
// app/dashboard/loading.js
export default function Loading() {
  return (
    <div className="space-y-4">
      <div className="h-8 w-48 animate-pulse rounded bg-slate-200" />
      <div className="h-64 animate-pulse rounded bg-slate-200" />
    </div>
  )
}
tsx
// app/dashboard/page.tsx
import { getStats } from '@/lib/stats'

export default async function Page() {
  const stats = await getStats()      // 2 seconds
  return <StatsGrid stats={stats} />
}

What actually happens under the hood — Next.js builds this for you:

tsx
<Layout>
  <Suspense fallback={<Loading />}>
    <Page />
  </Suspense>
</Layout>

Which means the layout renders and streams immediately, and the page slots in when ready. The user sees the sidebar and header instantly.

loading.tsx applies to its segment and all segments below it, until a deeper loading.tsx overrides it.

It also handles navigation

loading.tsx isn't only for first page load. When a user clicks a <Link> to a slow route, the fallback appears immediately and the navigation is interruptible — the user can click a different link and the router abandons the in-flight one. No frozen UI.

🧩 Manual <Suspense> — the useful version

loading.tsx is all-or-nothing: the whole page is either loading or not. Real pages are rarely that uniform.

Consider a dashboard where the user profile is instant, revenue takes 200ms, and the analytics chart takes 3 seconds:

tsx
// ❌ everything waits for the slowest query
export default async function Page() {
  const user = await getUser()          // 10ms
  const revenue = await getRevenue()    // 200ms
  const analytics = await getAnalytics() // 3000ms
  // total: 3.2s before anything renders

  return (
    <>
      <Greeting user={user} />
      <RevenueCard revenue={revenue} />
      <AnalyticsChart data={analytics} />
    </>
  )
}

Break it apart:

tsx
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { getUser } from '@/lib/user'

export default async function Page() {
  const user = await getUser()          // fast — block on this

  return (
    <>
      <Greeting user={user} />

      <Suspense fallback={<CardSkeleton />}>
        <RevenueCard />
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <AnalyticsChart />
      </Suspense>
    </>
  )
}

async function RevenueCard() {
  const revenue = await getRevenue()
  return <div className="card">${revenue.total}</div>
}

async function AnalyticsChart() {
  const data = await getAnalytics()
  return <Chart data={data} />
}
jsx
// app/dashboard/page.js
import { Suspense } from 'react'
import { getUser } from '@/lib/user'

export default async function Page() {
  const user = await getUser()

  return (
    <>
      <Greeting user={user} />

      <Suspense fallback={<CardSkeleton />}>
        <RevenueCard />
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <AnalyticsChart />
      </Suspense>
    </>
  )
}

async function RevenueCard() {
  const revenue = await getRevenue()
  return <div className="card">${revenue.total}</div>
}

async function AnalyticsChart() {
  const data = await getAnalytics()
  return <Chart data={data} />
}

Timeline:

  0ms    ──► greeting + two skeletons visible
 200ms   ──► revenue card streams in
3000ms   ──► chart streams in

The user has a usable page at 0ms instead of 3.2 seconds. Nothing about the total server work changed — only when the user gets to see it.

The pattern to internalize: move the await into the component that needs the data, then wrap that component in <Suspense>. Data fetching goes down; the boundary goes around.

Suspense boundaries also parallelize

A subtle bonus. In the sequential version, getRevenue() couldn't start until getUser() resolved. In the Suspense version, RevenueCard and AnalyticsChart both start rendering immediately, so their fetches run concurrently.

You get parallelism for free by splitting the components.

🎨 Designing good fallbacks

A skeleton that matches the real content's shape prevents layout shift and feels dramatically better than a spinner.

tsx
// components/skeletons.tsx
export function CardSkeleton() {
  return (
    <div className="rounded-lg border p-6">
      <div className="mb-3 h-4 w-24 animate-pulse rounded bg-slate-200" />
      <div className="h-8 w-32 animate-pulse rounded bg-slate-200" />
    </div>
  )
}

export function TableSkeleton({ rows = 5 }: { rows?: number }) {
  return (
    <div className="space-y-2">
      {Array.from({ length: rows }).map((_, i) => (
        <div key={i} className="h-12 animate-pulse rounded bg-slate-100" />
      ))}
    </div>
  )
}
jsx
// components/skeletons.js
export function CardSkeleton() {
  return (
    <div className="rounded-lg border p-6">
      <div className="mb-3 h-4 w-24 animate-pulse rounded bg-slate-200" />
      <div className="h-8 w-32 animate-pulse rounded bg-slate-200" />
    </div>
  )
}

export function TableSkeleton({ rows = 5 }) {
  return (
    <div className="space-y-2">
      {Array.from({ length: rows }).map((_, i) => (
        <div key={i} className="h-12 animate-pulse rounded bg-slate-100" />
      ))}
    </div>
  )
}

Rules that hold up:

  1. Match the dimensions of the real content. A 200px skeleton replaced by 400px of content causes a jarring jump.
  2. Match the count where you know it. Ten rows of skeleton for a ten-row table.
  3. Don't animate too fast. A subtle pulse reads as "loading"; a strobe reads as "broken".
  4. Skip skeletons for genuinely fast content. A skeleton that flashes for 50ms is worse than no skeleton — it reads as a glitch.

🌊 How streaming actually works

Under the hood it's HTTP chunked transfer encoding. The server sends the shell first, then patches:

html
<!-- chunk 1, sent immediately -->
<html>
  <body>
    <nav>...</nav>
    <h1>Dashboard</h1>
    <div id="B:0"><div class="skeleton"></div></div>
    <div id="B:1"><div class="skeleton"></div></div>

<!-- chunk 2, sent 200ms later -->
    <template id="S:0"><div class="card">$42,000</div></template>
    <script>/* React swaps B:0 for S:0 */</script>

<!-- chunk 3, sent 3s later -->
    <template id="S:1"><canvas>...</canvas></template>
    <script>/* React swaps B:1 for S:1 */</script>
  </body>
</html>

Three consequences worth knowing:

1. Content outside a Suspense boundary blocks the shell. If you await something slow directly in the page body, nothing streams until it resolves. The boundary is what lets React send the shell.

2. Streaming works without JavaScript for the initial render. React uses inline <script> tags to swap content, but the HTML arrives regardless — crawlers and no-JS clients see the complete page.

3. SEO is fine. Google's crawler waits for the stream to complete before indexing. Streamed content is indexed.

🔀 Streaming + parallel routes

Chapter 7's slots stream independently. Combining them gives per-panel loading states with zero manual Suspense:

app/dashboard/
├── layout.tsx
├── @revenue/
│   ├── loading.tsx
│   ├── page.tsx
│   └── default.tsx
└── @activity/
    ├── loading.tsx
    ├── page.tsx
    └── default.tsx

Each slot gets its own boundary automatically. This is often cleaner than hand-placed <Suspense> when the panels are genuinely independent routes.

📡 Streaming with after()

Sometimes work should happen after the response is sent — logging, analytics, cache warming. after() schedules it without delaying the user:

tsx
// app/blog/[slug]/page.tsx
import { after } from 'next/server'
import { logView } from '@/lib/analytics'

export default async function Page(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params
  const post = await getPost(slug)

  after(async () => {
    await logView(slug)      // runs after the response is streamed
  })

  return <article>{post.body}</article>
}
jsx
// app/blog/[slug]/page.js
import { after } from 'next/server'
import { logView } from '@/lib/analytics'

export default async function Page({ params }) {
  const { slug } = await params
  const post = await getPost(slug)

  after(async () => {
    await logView(slug)
  })

  return <article>{post.body}</article>
}

Without after(), that analytics write is on the critical path and the user waits for it.

⚠️ Common Pitfalls

1. Awaiting slow data outside a boundary

tsx
// ❌ nothing streams — the whole page waits
export default async function Page() {
  const data = await slowQuery()     // 3s
  return (
    <>
      <Header />
      <Suspense fallback={<Skeleton />}>
        <Chart data={data} />        {/* the Suspense is useless here */}
      </Suspense>
    </>
  )
}

The await is in the page body, so the page can't render at all until it resolves. The boundary wraps a component that already has its data.

tsx
// ✅ move the await inside
export default function Page() {
  return (
    <>
      <Header />
      <Suspense fallback={<Skeleton />}>
        <Chart />
      </Suspense>
    </>
  )
}

async function Chart() {
  const data = await slowQuery()
  return <canvas>{/* ... */}</canvas>
}

The await must live inside the suspended component. This is the single most common streaming mistake.

2. One boundary around everything

tsx
// ❌ back to all-or-nothing
<Suspense fallback={<PageSkeleton />}>
  <Everything />
</Suspense>

That's just loading.tsx with extra steps. Boundaries earn their value at the granularity of independently-slow sections.

3. Too many boundaries

tsx
// ❌ visual chaos
{items.map((item) => (
  <Suspense key={item.id} fallback={<Skeleton />}>
    <Item id={item.id} />
  </Suspense>
))}

Fifty items popping in at random times looks broken. Wrap the list, not each item.

4. Sequential awaits inside one component

tsx
// ❌ 600ms
async function Panel() {
  const a = await getA()    // 200ms
  const b = await getB()    // 200ms
  const c = await getC()    // 200ms
  return <View a={a} b={b} c={c} />
}

// ✅ 200ms
async function Panel() {
  const [a, b, c] = await Promise.all([getA(), getB(), getC()])
  return <View a={a} b={b} c={c} />
}

Suspense doesn't parallelize awaits within a component. Use Promise.all for independent requests. Chapter 11 goes deeper.

5. Layout shift from mismatched skeletons

Measure the real component and match its height. min-height on the container is a cheap safety net.

6. Fallbacks that flash

If data usually resolves in 30ms, the skeleton appears and vanishes before the eye registers it — reading as a flicker. Either skip the boundary for fast content, or render the previous data during a transition instead.

7. Expecting loading.tsx to help a Client Component fetch

tsx
'use client'
export default function Page() {
  const { data } = useSWR('/api/stats')    // loading.tsx already dismissed
  if (!data) return <Spinner />
}

loading.tsx covers the server render of the route. A client-side fetch starting after hydration is your own responsibility.

🎯 When & Why to Use

loading.tsx when:
  ✅ The whole route is slow and uniformly so
  ✅ You want route-level navigation feedback for free
  ✅ Quick win — one file, no restructuring

Manual <Suspense> when:
  ✅ Parts of the page have very different speeds
  ✅ Something fast should render before something slow
  ✅ You want independent fetches to run concurrently

Parallel route slots when:
  ✅ Sections are independent enough to be their own routes
  ✅ Each needs its own loading AND error handling

Neither when:
  ❌ Everything resolves in under ~100ms — skeletons add noise
  ❌ Data is already cached or static

A practical starting point: add loading.tsx to every route that touches a database. Then profile, find the one slow section, and give it its own boundary.

🏋️ Mini Practice Problems

Problem 1: Fix the streaming

Why does this page take 4 seconds to show anything, and how do you fix it?

tsx
export default async function Page() {
  const posts = await getPosts()        // 4s

  return (
    <>
      <h1>Blog</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <PostList posts={posts} />
      </Suspense>
    </>
  )
}

Problem 2: Predict the timeline

Given these durations, when does each element become visible?

tsx
export default async function Page() {
  const user = await getUser()          // 50ms

  return (
    <>
      <h1>Hi {user.name}</h1>
      <Suspense fallback={<A />}>
        <Slow />       {/* awaits 2000ms */}
      </Suspense>
      <Suspense fallback={<B />}>
        <Medium />     {/* awaits 500ms */}
      </Suspense>
    </>
  )
}

Write the timeline for: the <h1>, fallback A, fallback B, Medium, Slow.

Problem 3: Choose the boundaries

An e-commerce product page has:

  • Product name, price, images — from a fast cache (20ms)
  • Stock level — live inventory API (400ms)
  • Reviews — 1200ms
  • "Customers also bought" — 2500ms

Write the JSX with the right boundaries. Justify each one.

Problem 4: Spot the anti-pattern

tsx
export default async function Page() {
  return (
    <div>
      {['a', 'b', 'c', 'd', 'e'].map((id) => (
        <Suspense key={id} fallback={<Skeleton />}>
          <Widget id={id} />
        </Suspense>
      ))}
    </div>
  )
}

What's wrong, when would it actually be fine, and what's the fix?

💼 Interview Notes

Common Questions

Q: What is streaming and why does it matter? The server sends HTML in chunks as it becomes ready instead of waiting for the whole page. Users see and interact with the fast parts immediately while slow sections fill in. It improves perceived performance and Core Web Vitals without changing total server work.

Q: How does loading.tsx work? Next.js automatically wraps the segment's page in a <Suspense> boundary with loading.tsx as the fallback. It applies to that segment and everything below it, and also shows during client-side navigation to that route.

Q: When would you use manual <Suspense> over loading.tsx? When parts of a page have very different latencies. loading.tsx is all-or-nothing at the route level; manual boundaries let fast content render immediately while individual slow sections stream in — and let their fetches run concurrently.

Q: Why doesn't my Suspense boundary do anything? Almost always because the await is in the parent component rather than inside the suspended child. The boundary can only suspend on work that happens beneath it.

Q: Does streaming hurt SEO? No. Crawlers wait for the response to complete and index the full content. The HTML is real HTML, not client-rendered.

Q: How does Suspense affect parallel data fetching? Splitting slow work into separate suspended components makes their fetches start concurrently instead of sequentially. Within a single component, you still need Promise.all — Suspense doesn't parallelize awaits in the same function.

🏢 Asked at Companies

  • Vercel: "A page has one 3-second query. Walk me through making it feel fast without making the query faster."
  • Amazon: "Design the Suspense boundaries for a product detail page and defend each one."
  • Netflix: "Explain the difference between a loading spinner and streaming, technically and in what the user experiences."
  • Shopify: "A developer wrapped every product card in its own Suspense boundary. What's your review comment?"

📊 Visual Memory Aid

              WITHOUT STREAMING

  request ──► [ ████ 3s of server work ████ ] ──► full HTML
              user sees: blank ─────────────────► everything

              WITH STREAMING

  request ──► [shell] ──► [chunk] ──► [chunk]
              0ms         200ms       3000ms
              │           │           │
              nav +       revenue     chart
              skeletons   arrives     arrives


              WHERE THE AWAIT GOES

  ❌ const data = await slow()         ← blocks the shell
     <Suspense><Chart data={data}/></Suspense>

  ✅ <Suspense><Chart /></Suspense>    ← streams
     async function Chart() {
       const data = await slow()       ← await lives INSIDE
     }


              BOUNDARY GRANULARITY

  loading.tsx        │ ████████████████ │  whole route
  one <Suspense>     │ ██ │ ████████████ │  fast + slow
  per-section        │ ██ │ ███ │ ██████ │  ← usually right
  per-item           │█│█│█│█│█│█│█│█│█│  ← too much

🎯 Key Takeaways

  1. Streaming sends the page in chunks so users see the shell in milliseconds instead of waiting for the slowest query.
  2. loading.tsx is the free version — one file wraps the route in Suspense and also covers client-side navigation.
  3. Manual <Suspense> is the precise version — put boundaries around independently-slow sections so fast content renders first.
  4. The await must be inside the suspended component. Awaiting in the parent defeats the boundary entirely — this is the mistake to check first when streaming "isn't working".
  5. Suspense parallelizes across components, not within one. Use Promise.all for independent fetches in the same function.

Next Chapter: Error Handling →

Practice: Build a dashboard with three panels at 100ms, 1s, and 3s. Ship it with a single loading.tsx, then refactor to per-panel <Suspense> boundaries. Compare both in the Network tab with throttling on, and watch when each chunk arrives.


PreviousChapter 7: Parallel & Intercepting RoutesNextChapter 9: Error Handling

Open source, free forever. Built by iammhador.

Contribute on GitHub