React Suspense — Streaming, Code Splitting, and Async Data Loading
Advertisement
Introduction
Why This Matters
Before Suspense, a slow data fetch would block the entire page from rendering. With Suspense and Next.js App Router streaming, the browser can receive and display a page skeleton immediately, then stream in data-dependent sections as they become ready. This dramatically improves Time to First Byte (TTFB) and Largest Contentful Paint (LCP) scores.
How Suspense Works
Wrap any component that might suspend (pause rendering while waiting for async work) in <Suspense>. React renders the fallback immediately and swaps in the real component once it is ready.
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<div className="grid gap-6">
<h1 className="text-2xl font-bold">Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<Stats /> {/* Fetches analytics data — may take 200ms */}
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<RecentActivity /> {/* Fetches activity — may take 400ms */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* Fetches chart data — may take 600ms */}
</Suspense>
</div>
)
}Each <Suspense> boundary is independent. Stats and RecentActivity render as soon as they are ready without waiting for each other.
Async Server Components and Streaming
In Next.js App Router, async Server Components automatically integrate with Suspense. Any await inside a Server Component causes it to suspend.
// components/user-stats.tsx — Server Component
async function UserStats({ userId }: { userId: string }) {
// This await suspends the component until data is ready
const stats = await db.userStats.findUnique({ where: { userId } })
return (
<div className="grid grid-cols-3 gap-4">
<StatCard label="Posts" value={stats.postCount} />
<StatCard label="Followers" value={stats.followerCount} />
<StatCard label="Views" value={stats.totalViews} />
</div>
)
}
// Skeleton shown while stats load
function StatsSkeleton() {
return (
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-20 bg-gray-200 rounded-lg animate-pulse" />
))}
</div>
)
}Code Splitting with Dynamic Imports
Use next/dynamic with Suspense for heavy client components:
import dynamic from 'next/dynamic'
import { Suspense } from 'react'
// The chart library (~200KB) only loads when the component renders
const RevenueChart = dynamic(() => import('@/components/revenue-chart'), {
ssr: false
})
const RichTextEditor = dynamic(() => import('@/components/rich-text-editor'), {
loading: () => <div className="h-40 bg-gray-100 rounded animate-pulse" />
})
export default function AnalyticsPage() {
return (
<div>
<Suspense fallback={<div className="h-64 bg-gray-100 rounded animate-pulse" />}>
<RevenueChart />
</Suspense>
</div>
)
}Nested Suspense Boundaries
Design Suspense boundaries around independent data dependencies to parallelize loading:
// app/blog/[slug]/page.tsx
async function PostPage({ params }: { params: { slug: string } }) {
// Post metadata loads fast — no suspense needed
const post = await db.posts.findUnique({ where: { slug: params.slug } })
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{/* Comments may be slow — stream them in separately */}
<Suspense fallback={<CommentsSkeleton count={3} />}>
<Comments postId={post.id} />
</Suspense>
{/* Related posts fetch independently */}
<Suspense fallback={<RelatedSkeleton />}>
<RelatedPosts tag={post.tags[0]} currentId={post.id} />
</Suspense>
</article>
)
}Loading UI with loading.tsx
Next.js automatically wraps your page in a Suspense boundary when you create a loading.tsx file in the same route segment:
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="space-y-6 p-6">
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse" />
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-24 bg-gray-200 rounded-lg animate-pulse" />
))}
</div>
<div className="h-64 bg-gray-200 rounded-lg animate-pulse" />
</div>
)
}This is equivalent to wrapping the page in <Suspense fallback={<DashboardLoading />}>.
Common Mistakes
- Placing one large Suspense boundary around the entire page — this blocks the whole page until all data is ready, negating streaming benefits
- Using Suspense for error handling — use Error Boundaries (
error.tsx) for errors, Suspense only for async loading - Forgetting to create skeleton components that match the shape of the loaded content — layout shift hurts UX and Core Web Vitals
- Wrapping a component in Suspense when it does not actually suspend — the fallback never shows
Best Practices
- Place Suspense boundaries around the smallest independently-loadable units of your UI
- Always create accurate skeleton components that match the dimensions of the real content to minimize Cumulative Layout Shift
- Use
loading.tsxfor route-level loading states and component-level<Suspense>for granular streaming - Defer non-critical sections (comments, recommendations) behind Suspense to prioritize above-the-fold content
Key Takeaways
- Suspense boundaries let React render fallback UI immediately while async work completes in the background
- In Next.js App Router, async Server Components automatically suspend at any
awaitexpression - Multiple sibling Suspense boundaries stream their content independently and in parallel
loading.tsxis syntactic sugar for a Suspense boundary wrapping the entire route segmentnext/dynamicwith Suspense splits JavaScript bundles so heavy libraries only load on demand- Nested Suspense boundaries are the correct way to prioritize above-the-fold content over below-the-fold content
- Use Error Boundaries (
error.tsx) for error states — Suspense is only for async loading, not error handling - Skeleton UIs must match the dimensions of real content to prevent layout shift that hurts Core Web Vitals
Advertisement