Suspense Pattern in React
Master the React Suspense pattern — render-as-you-fetch, use() API, fallback boundaries, resources architecture, Error Boundary integration, Next.js streaming, and vs fetch-on-render.
Introduction
The Suspense pattern shifts async UI from imperative loading flags to declarative boundaries — wrap async regions in <Suspense fallback={…}>, let components suspend until data is ready, and stream each region independently. React 19's use() hook reads promises during render and throws to the nearest Suspense boundary until they resolve.
This guide covers:
- Traditional fetching problems — fetch-on-render, waterfalls, manual state
- Suspense mechanism — declarative async, fallback UI, suspend until ready
- React 19
useAPI — promise-driven render, replaces fetch-in-useEffect - Error Boundary integration — rejected promises, retry, isolated failures
- Implementation architecture — resources folder, chained promises, selective loading
- Benefits — leaner code, perceived performance, organized dependencies
- Best practices & pitfalls — boundaries, Next.js
loading.tsx, when not to Suspense
Quick index
| # | Section |
|---|---|
| 1 | Traditional data fetching |
| 2 | Suspense mechanism |
| 3 | React 19 use API |
| 4 | Error Boundary integration |
| 5 | Implementation architecture |
| 6 | Full example: Account dashboard |
| 7 | Benefits |
| 8 | Best practices & pitfalls |
1. Traditional data fetching
Before Suspense, most React apps used fetch-on-render — data loading starts inside useEffect after the first paint.
| Pattern | Problem |
|---|---|
| Fetch-on-render | Request starts late — after component mounts |
useEffect + useState | Manual loading, error, data in every screen |
| Imperative state management | Three branches duplicated across features |
| Waterfall / blocking UI | Child waits for parent fetch before starting own |
| Manual loading/error logic | Boilerplate obscures actual UI |
Fetch-on-render waterfall
'use client'
import { useEffect, useState } from 'react'
function ProfilePage({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then(setUser)
.finally(() => setLoading(false))
}, [userId])
if (loading) return <p>Loading profile…</p>
if (!user) return <p>Not found</p>
return (
<div>
<h1>{user.name}</h1>
{/* Orders only starts fetching AFTER profile finishes — waterfall */}
<OrdersSection userId={user.id} />
</div>
)
}
function OrdersSection({ userId }: { userId: string }) {
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch(`/api/users/${userId}/orders`)
.then((r) => r.json())
.then(setOrders)
.finally(() => setLoading(false))
}, [userId])
if (loading) return <p>Loading orders…</p>
return <OrdersList orders={orders} />
}Timeline (waterfall):
Profile fetch ████████░░░░░░░░
Orders fetch ████████░░
Total wait ████████████████2. Suspense mechanism
Suspense inverts the model: render-as-you-fetch — start requests early, declare fallback UI at boundaries, let React pause subtrees until promises resolve.
| Concept | Meaning |
|---|---|
| Render-as-you-fetch | Initiate fetch before or during render — not after mount effect |
| Declarative async handling | <Suspense fallback={…}> instead of if (loading) |
| Suspense wrapper | Boundary around async child components |
| Fallback UI / skeletons | Placeholder shown while subtree is suspended |
| Suspends until ready | React pauses rendering until promise resolves |
import { Suspense } from 'react'
function PanelSkeleton() {
return <div className="h-48 animate-pulse rounded bg-neutral-200" />
}
function ReportsPage() {
return (
<section>
<h1>Reports</h1>
{/* Shell renders immediately — chart streams in when ready */}
<Suspense fallback={<PanelSkeleton />}>
<AnalyticsPanel />
</Suspense>
<Suspense fallback={<p>Loading export history…</p>}>
<ExportHistory />
</Suspense>
</section>
)
}Mental model
ReportsPage renders immediately (h1 visible)
→ AnalyticsPanel suspends → fallback skeleton shown
→ ExportHistory suspends independently → its own fallback
→ each resolves → real content replaces fallback3. React 19 use API
React 19's use() reads a promise or Context during render. If the promise is pending, React throws it to the nearest Suspense boundary; when resolved, use returns the value.
| Behavior | Detail |
|---|---|
| Reads promise or Context | use(promise), use(ThemeContext) |
| Replaces fetch in useEffect | Data access colocated with render logic |
| Returns data when resolved | Synchronous value after promise settles |
| Throws to Suspense | Pending promise suspends the subtree |
Resource cache — read promise once
type User = { id: string; name: string; email: string }
const cache = new Map<string, Promise<User>>()
export function fetchUser(userId: string): Promise<User> {
if (!cache.has(userId)) {
const promise = fetch(`/api/users/${userId}`).then((r) => {
if (!r.ok) throw new Error('User fetch failed')
return r.json()
})
cache.set(userId, promise)
}
return cache.get(userId)!
}Component with use()
'use client'
import { use, Suspense } from 'react'
import { fetchUser } from '@/resources/user'
function ProfileContent({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise)
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}
export function ProfilePanel({ userId }: { userId: string }) {
const userPromise = fetchUser(userId)
return (
<Suspense fallback={<div className="h-24 animate-pulse rounded bg-neutral-200" />}>
<ProfileContent userPromise={userPromise} />
</Suspense>
)
}4. Error Boundary integration
When a suspended promise rejects, Suspense alone cannot render an error UI — wrap async regions with an Error Boundary.
| Integration piece | Role |
|---|---|
| Catches rejected promises | Failed fetch propagates to boundary |
| Prevents entire UI crash | Only the async region shows error fallback |
| Fallback error UI | Actionable message instead of white screen |
| Retry functionality | Reset boundary + remount to re-read promise |
'use client'
import { Suspense, use } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { fetchUser } from '@/resources/user'
function ProfileInner({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise)
return <h2>{user.name}</h2>
}
function ProfileError({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) {
return (
<div role="alert" className="rounded border border-red-200 bg-red-50 p-4">
<p>Profile failed: {error.message}</p>
<button type="button" onClick={resetErrorBoundary}>
Retry
</button>
</div>
)
}
export function SafeProfilePanel({ userId }: { userId: string }) {
const userPromise = fetchUser(userId)
return (
<ErrorBoundary
FallbackComponent={ProfileError}
onReset={() => {
// Invalidate cache entry so retry fetches fresh
invalidateUserCache(userId)
}}>
<Suspense fallback={<div className="h-12 animate-pulse rounded bg-neutral-200" />}>
<ProfileInner userPromise={userPromise} />
</Suspense>
</ErrorBoundary>
)
}Boundary ordering
ErrorBoundary ← catches rejections
└── Suspense ← catches pending (shows skeleton)
└── Component ← calls use(promise)5. Implementation architecture
Organize Suspense apps with a resources folder — promises created at entry points, consumed deep in the tree via use().
| Architecture piece | Purpose |
|---|---|
| Resources folder | resources/user.ts, resources/orders.ts |
| Initialize at entry point | Page/loader starts fetches before children |
| Chain dependent promises | Orders fetch receives userId from user promise |
| Component hierarchy | Profile, Orders, Analytics — each own boundary |
| Selective / non-blocking load | Regions appear independently |
Resources module pattern
// resources/user.ts
const userCache = new Map<string, Promise<User>>()
export function loadUser(userId: string): Promise<User> {
if (!userCache.has(userId)) {
userCache.set(
userId,
fetch(`/api/users/${userId}`).then((r) => {
if (!r.ok) throw new Error('User not found')
return r.json()
}),
)
}
return userCache.get(userId)!
}
export function invalidateUser(userId: string) {
userCache.delete(userId)
}
// resources/orders.ts
const ordersCache = new Map<string, Promise<Order[]>>()
export function loadOrders(userId: string): Promise<Order[]> {
const key = userId
if (!ordersCache.has(key)) {
ordersCache.set(
key,
fetch(`/api/users/${userId}/orders`).then((r) => r.json()),
)
}
return ordersCache.get(key)!
}Chained dependent promises at page entry
// app/account/[userId]/page.tsx
import { loadUser } from '@/resources/user'
import { loadOrders } from '@/resources/orders'
import { AccountDashboard } from './AccountDashboard'
export default function AccountPage({ params }: { params: { userId: string } }) {
const userPromise = loadUser(params.userId)
// Start orders fetch as soon as user id is known — parallel with profile render path
const ordersPromise = loadOrders(params.userId)
return <AccountDashboard userPromise={userPromise} ordersPromise={ordersPromise} />
}6. Full example: Account dashboard
Three independent regions — Profile, Orders, Analytics — each with Suspense + Error Boundary.
'use client'
import { Suspense, use } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import type { User, Order } from '@/types'
function SectionSkeleton({ height = 'h-32' }: { height?: string }) {
return <div className={`${height} animate-pulse rounded bg-neutral-200`} />
}
function SectionError({ label, reset }: { label: string; reset: () => void }) {
return (
<div className="rounded border border-dashed p-4 text-sm text-neutral-500">
<p>{label} failed to load.</p>
<button type="button" className="mt-2 underline" onClick={reset}>
Retry
</button>
</div>
)
}
function ProfileSection({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise)
return (
<section>
<h2 className="font-bold">Profile</h2>
<p>{user.name}</p>
<p className="text-sm text-neutral-500">{user.email}</p>
</section>
)
}
function OrdersSection({ ordersPromise }: { ordersPromise: Promise<Order[]> }) {
const orders = use(ordersPromise)
return (
<section>
<h2 className="font-bold">Orders ({orders.length})</h2>
<ul className="text-sm">
{orders.map((o) => (
<li key={o.id}>
#{o.id} — ${o.total}
</li>
))}
</ul>
</section>
)
}
function AnalyticsSection({ analyticsPromise }: { analyticsPromise: Promise<{ visits: number }> }) {
const stats = use(analyticsPromise)
return (
<section>
<h2 className="font-bold">Analytics</h2>
<p>{stats.visits.toLocaleString()} visits this month</p>
</section>
)
}
type AccountDashboardProps = {
userPromise: Promise<User>
ordersPromise: Promise<Order[]>
analyticsPromise: Promise<{ visits: number }>
}
export function AccountDashboard({ userPromise, ordersPromise, analyticsPromise }: AccountDashboardProps) {
return (
<div className="grid gap-6 md:grid-cols-2">
<ErrorBoundary fallbackRender={({ resetErrorBoundary }) => <SectionError label="Profile" reset={resetErrorBoundary} />}>
<Suspense fallback={<SectionSkeleton />}>
<ProfileSection userPromise={userPromise} />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallbackRender={({ resetErrorBoundary }) => <SectionError label="Orders" reset={resetErrorBoundary} />}>
<Suspense fallback={<SectionSkeleton />}>
<OrdersSection ordersPromise={ordersPromise} />
</Suspense>
</ErrorBoundary>
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) => <SectionError label="Analytics" reset={resetErrorBoundary} />}>
<Suspense fallback={<SectionSkeleton height="h-48" />}>
<AnalyticsSection analyticsPromise={analyticsPromise} />
</Suspense>
</ErrorBoundary>
</div>
)
}Code splitting with lazy() — same boundary pattern
import { Suspense, lazy } from 'react'
const HeavyChart = lazy(() => import('./HeavyChart'))
function ChartCard() {
return (
<Suspense fallback={<SectionSkeleton height="h-64" />}>
<HeavyChart />
</Suspense>
)
}7. Benefits
| Benefit | Outcome |
|---|---|
| Cleaner, leaner code | No if (loading) return … in every component |
| Faster perceived performance | Shell + fast regions paint before slow data |
| Organized data dependencies | Resources folder maps data to features explicitly |
| Improved developer experience | Declarative mental model for async UI |
| Streaming (SSR) | Server sends HTML incrementally per boundary |
| Parallel fetching | Entry-point promises avoid waterfalls |
Before vs after — line count mindset
// Before — 15+ lines of state branches per screen
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
if (loading) return <Spinner />
if (error) return <Error />
return <UI data={data} />
// After — boundary + use()
<Suspense fallback={<Spinner />}>
<UI userPromise={userPromise} />
</Suspense>
// UI inner: const user = use(userPromise)8. Best practices & pitfalls
| Practice / pitfall | Guidance |
|---|---|
| New promise every render | Cache in resources module by key |
| One giant Suspense boundary | Split per widget — avoid blocking whole page |
| Suspense without Error Boundary | Rejected promises need boundary wrapper |
| use() in leaf without provider | Ensure ancestor Suspense exists |
| TanStack Query + Suspense | Use useSuspenseQuery with boundaries |
Client-only for use() | Mark 'use client' where use(promise) runs |
Next.js route loading boundary
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="space-y-4 p-6">
<div className="h-8 w-48 animate-pulse rounded bg-neutral-200" />
<div className="h-64 animate-pulse rounded bg-neutral-200" />
</div>
)
}Summary
| Piece | Role |
|---|---|
| Resources | Cached promises — fetch once, read many |
use(promise) | Suspend until resolved |
<Suspense> | Declarative loading fallback |
| Error Boundary | Declarative failure fallback |
| Entry-point fetch | Parallel requests — no waterfall |
The Suspense pattern replaces imperative loading flags with declarative boundaries. Combine render-as-you-fetch, React 19's use API, granular fallbacks, and Error Boundary wrappers for resilient, streaming-friendly React apps.
Next reads: Error Boundary Pattern for rejection handling and retry, Performance Guide (Part 1) for perceived performance patterns, and React Design Patterns for the full pattern roadmap.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime