Suspense and Streaming
Your dashboard takes 4 seconds to load because one slow API blocks everything. Users stare at a blank screen, bounce, and never see the content that was ready in 200ms.
Traditional server rendering waits for ALL data before sending ANY HTML. Streaming flips this: send the shell immediately, let slow components trickle in as they resolve. Same total load time, completely different user experience.
In Lesson 3.1: Cache Components, you wrapped async components in <Suspense> to create "dynamic holes" in static shells. This lesson dives deeper: how streaming works at the HTTP level, strategic boundary placement, and avoiding "spinner soup."
VIDEO PLACEHOLDER · 5-7 min · EVERGREEN · High Priority
What Streaming Actually Means: The HTTP Response Timeline
Visual walkthrough of how streaming works at the HTTP level - shell HTML sent immediately, Suspense boundaries as placeholders, then HTML chunks streaming in as components resolve. Shows the actual network waterfall in DevTools and how React hydrates progressively.
Outcome
A page with component-level Suspense boundaries, minimal fallbacks, and streamed content. Shell renders instantly while slower sections stream in as ready.
Fast Track
- Use
loading.tsxto wrap entire route segment in Suspense automatically. - Add component-level
<Suspense>boundaries for granular progressive loading. - Keep fallbacks minimal and meaningful - avoid "spinner soup" with too many nested boundaries.
Hands-On Exercise 3.3
The starter repo includes a profile page at apps/web/src/app/profile/[id]/page.tsx that already uses one Suspense boundary. Your task is to improve streaming granularity.
Requirements:
- Add
loading.tsxtoapps/web/src/app/profile/[id]/for route-level streaming with skeleton fallback. - Split
ProfileContentinto separate async components:ProfileHeader,ProfileStats,ProfileActivity. - Wrap each component in its own Suspense boundary so they stream independently.
- Keep fallbacks minimal and shaped like the actual content (skeleton loaders, not spinners).
Implementation hints:
loading.tsxautomatically wraps page in Suspense boundary.- Component-level Suspense for granular control over streaming.
- Avoid deeply nested boundaries - creates confusing loading states.
- Fallbacks should match the shape of actual content (skeleton loaders).
- Test with slow 3G throttling in DevTools to verify streaming behavior.
I'm implementing streaming with Suspense and need help deciding where to place boundaries.
<context>
Too many Suspense boundaries = "spinner soup" (confusing loading states)
Too few boundaries = lose streaming benefits (everything waits for slowest component)
Goal: 2-3 boundaries max, placed strategically
</context>
<my-page-structure>
**Components on this page:**
| Component | Data Source | Load Time | Above Fold? |
| --------- | ----------- | --------- | ----------- |
| _____ | _____ | _____ms | Yes/No |
| _____ | _____ | _____ms | Yes/No |
| _____ | _____ | _____ms | Yes/No |
Example:
| Component | Data Source | Load Time | Above Fold? |
|-----------|-------------|-----------|-------------|
| ProfileHeader | /api/user | 200ms | Yes |
| ProfileStats | /api/stats | 500ms | Yes |
| ActivityFeed | /api/activity | 800ms | No |
</my-page-structure>
<current-implementation>
```tsx
// My current page structure:
___PASTE_YOUR_PAGE_CODE___
```
Example:
```tsx
export default async function Page() {
return (
<main>
<ProfileHeader />
<ProfileStats />
<ActivityFeed />
</main>
);
}
```
</current-implementation>
**Questions:**
1. How many Suspense boundaries should I use?
2. Should I group components or separate them?
3. Spinners or skeleton loaders for fallbacks?
4. Which components should stream first?
Generate a Suspense boundary strategy with:
- Component groupings
- Fallback components (prefer skeletons)
- Streaming order rationaleTry It
-
Test streaming behavior:
- Open page with DevTools Network tab throttled to "Slow 3G".
- Shell and static content render immediately.
- Suspense boundaries show fallbacks.
- Content streams in as data becomes available.
-
Verify perceived performance:
- Measure Time to First Byte (TTFB) - should be fast for shell.
- Largest Contentful Paint (LCP) improves when above-fold content streams early.
- No blocking on slow data fetching.
Commit & Deploy
git add -A
git commit -m "feat(advanced): add suspense boundaries and streaming"
git push -u origin feat/advanced-suspense-streamingDone-When
- Open DevTools Network tab, throttle to "Slow 3G", reload page: see shell HTML arrive immediately (check waterfall)
- Page shows skeleton fallbacks while components load:
loading.tsxskeleton visible before content appears - After full page load, content streams in without full page re-render: each Suspense boundary resolves independently
- DevTools Performance tab recording shows multiple streaming chunks, not single blocking response
- No "spinner soup": maximum 2-3 skeleton loaders visible at once, not dozens of nested spinners
Solution
Click to reveal solution
First, add a route-level loading fallback:
export default function ProfileLoading() {
return (
<div className="mx-auto max-w-2xl animate-pulse p-8">
<div className="mb-2 h-8 w-48 rounded bg-gray-200" />
<div className="mb-6 h-4 w-32 rounded bg-gray-200" />
<div className="mb-6 flex gap-4">
<div className="h-6 w-20 rounded bg-gray-200" />
<div className="h-6 w-20 rounded bg-gray-200" />
<div className="h-6 w-20 rounded bg-gray-200" />
</div>
<div className="space-y-2">
<div className="h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-full rounded bg-gray-200" />
</div>
</div>
)
}Then refactor the page to use component-level Suspense for granular streaming:
import { Suspense } from 'react'
// Mock data fetching functions
async function fetchUserProfile(id: string) {
await new Promise((resolve) => setTimeout(resolve, 200))
return {
id,
name: 'Demo User',
email: 'demo@example.com',
joinedAt: new Date('2024-01-15'),
}
}
async function fetchUserStats(id: string) {
await new Promise((resolve) => setTimeout(resolve, 400)) // Slower
return { posts: 42, followers: 1234, following: 567 }
}
async function fetchUserActivity(id: string) {
await new Promise((resolve) => setTimeout(resolve, 600)) // Slowest
return [
{ type: 'post', title: 'My first post', date: new Date() },
{ type: 'comment', title: 'Great article!', date: new Date() },
{ type: 'like', title: 'Liked a post', date: new Date() },
]
}
// Separate async components for independent streaming
async function ProfileHeader({ id }: { id: string }) {
const profile = await fetchUserProfile(id)
return (
<section>
<h1 className="font-bold text-2xl">{profile.name}</h1>
<p className="text-gray-600">{profile.email}</p>
</section>
)
}
async function ProfileStats({ id }: { id: string }) {
const stats = await fetchUserStats(id)
return (
<section className="flex gap-4">
<div><strong>{stats.posts}</strong> posts</div>
<div><strong>{stats.followers}</strong> followers</div>
<div><strong>{stats.following}</strong> following</div>
</section>
)
}
async function ProfileActivity({ id }: { id: string }) {
const activity = await fetchUserActivity(id)
return (
<section>
<h2 className="mb-2 font-semibold text-xl">Recent Activity</h2>
<ul className="space-y-2">
{activity.map((item, i) => (
<li key={i} className="text-gray-700">{item.title}</li>
))}
</ul>
</section>
)
}
// Minimal skeleton fallbacks that match content shape
function HeaderSkeleton() {
return (
<section className="animate-pulse">
<div className="mb-2 h-8 w-48 rounded bg-gray-200" />
<div className="h-4 w-32 rounded bg-gray-200" />
</section>
)
}
function StatsSkeleton() {
return (
<section className="flex animate-pulse gap-4">
<div className="h-6 w-20 rounded bg-gray-200" />
<div className="h-6 w-20 rounded bg-gray-200" />
<div className="h-6 w-20 rounded bg-gray-200" />
</section>
)
}
function ActivitySkeleton() {
return (
<section className="animate-pulse">
<div className="mb-2 h-6 w-32 rounded bg-gray-200" />
<div className="space-y-2">
<div className="h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-3/4 rounded bg-gray-200" />
</div>
</section>
)
}
export default async function ProfilePage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return (
<main className="mx-auto max-w-2xl space-y-6 p-8">
{/* Each section streams independently as it resolves */}
<Suspense fallback={<HeaderSkeleton />}>
<ProfileHeader id={id} />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<ProfileStats id={id} />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ProfileActivity id={id} />
</Suspense>
</main>
)
}Too many nested Suspense boundaries create confusing loading states. Keep boundaries shallow and strategic:
- ✅ 2-3 boundaries for independent slow sections
- ❌ Suspense around every component - creates "spinner soup"
- ✅ Fallbacks match content shape (skeleton loaders)
- ❌ Generic spinners everywhere
How streaming works:
- Shell HTML with static content sent immediately (fast TTFB)
- Suspense boundaries replaced with fallbacks
- Server continues rendering slow components
- HTML chunks stream in as components complete
- React hydrates and replaces fallbacks progressively
References
- https://nextjs.org/docs/app/getting-started/fetching-data
- https://nextjs.org/docs/app/api-reference/file-conventions/loading
- https://react.dev/reference/react/Suspense - React Suspense documentation
- https://react.dev/reference/react/lazy - React lazy loading for code splitting
Was this helpful?