Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
ReactReact 19Design PatternsSuspenseData Fetching

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.

Jul 5, 202613 min read

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:

  1. Traditional fetching problems — fetch-on-render, waterfalls, manual state
  2. Suspense mechanism — declarative async, fallback UI, suspend until ready
  3. React 19 use API — promise-driven render, replaces fetch-in-useEffect
  4. Error Boundary integration — rejected promises, retry, isolated failures
  5. Implementation architecture — resources folder, chained promises, selective loading
  6. Benefits — leaner code, perceived performance, organized dependencies
  7. Best practices & pitfalls — boundaries, Next.js loading.tsx, when not to Suspense

Note

Suspense handles expected waiting — loading skeletons while data resolves. Pair every Suspense region that reads remote data with an Error Boundary for rejected promises — see Error Boundary Pattern.

Quick index

#Section
1Traditional data fetching
2Suspense mechanism
3React 19 use API
4Error Boundary integration
5Implementation architecture
6Full example: Account dashboard
7Benefits
8Best practices & pitfalls

1. Traditional data fetching

Before Suspense, most React apps used fetch-on-render — data loading starts inside useEffect after the first paint.

PatternProblem
Fetch-on-renderRequest starts late — after component mounts
useEffect + useStateManual loading, error, data in every screen
Imperative state managementThree branches duplicated across features
Waterfall / blocking UIChild waits for parent fetch before starting own
Manual loading/error logicBoilerplate obscures actual UI

Fetch-on-render waterfall

tsx
'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} />
}
plaintext
Timeline (waterfall):
  Profile fetch ████████░░░░░░░░
  Orders fetch          ████████░░
  Total wait            ████████████████

Warning

Nested if (loading) return … in parent and child creates request waterfalls — total wait time is the sum of sequential fetches, not the max.

Back to index


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.

ConceptMeaning
Render-as-you-fetchInitiate fetch before or during render — not after mount effect
Declarative async handling<Suspense fallback={…}> instead of if (loading)
Suspense wrapperBoundary around async child components
Fallback UI / skeletonsPlaceholder shown while subtree is suspended
Suspends until readyReact pauses rendering until promise resolves
tsx
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

plaintext
ReportsPage renders immediately (h1 visible)
  → AnalyticsPanel suspends → fallback skeleton shown
  → ExportHistory suspends independently → its own fallback
  → each resolves → real content replaces fallback

Tip

Use skeleton loaders matched to final layout dimensions — reduces cumulative layout shift (CLS) compared to generic "Loading…" text.

Note

Suspense boundaries are composable — nest them for page → section → widget granularity. Smaller boundaries improve selective loading — fast regions appear while slow ones still suspend.

Back to index


3. 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.

BehaviorDetail
Reads promise or Contextuse(promise), use(ThemeContext)
Replaces fetch in useEffectData access colocated with render logic
Returns data when resolvedSynchronous value after promise settles
Throws to SuspensePending promise suspends the subtree

Resource cache — read promise once

tsx
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()

tsx
'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>
  )
}

Warning

Create the promise outside the component that calls use() when possible — or cache by key — so re-renders do not fire duplicate network requests. A new fetch() call every render creates a new pending promise every time.

Performance

Start fetchUser(userId) at the route entry (page or loader) and pass the promise down — Profile and Header can both use(samePromise) without duplicate requests when cached.

Back to index


4. Error Boundary integration

When a suspended promise rejects, Suspense alone cannot render an error UI — wrap async regions with an Error Boundary.

Integration pieceRole
Catches rejected promisesFailed fetch propagates to boundary
Prevents entire UI crashOnly the async region shows error fallback
Fallback error UIActionable message instead of white screen
Retry functionalityReset boundary + remount to re-read promise
tsx
'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

plaintext
ErrorBoundary          ← catches rejections
  └── Suspense         ← catches pending (shows skeleton)
        └── Component  ← calls use(promise)

Note

Error Boundary outside Suspense is the common stack — boundary catches both render throws and rejected promises from use(). See Error Boundary Pattern for retry keys and logging.

Interview Answer

"Suspense vs Error Boundary?"

Suspense = expected wait — show skeleton while promise pending. Error Boundary = unexpected failure — show error UI when promise rejects or render throws. Production async regions need both: Suspense for loading, Error Boundary for failure.

Back to index


5. Implementation architecture

Organize Suspense apps with a resources folder — promises created at entry points, consumed deep in the tree via use().

Architecture piecePurpose
Resources folderresources/user.ts, resources/orders.ts
Initialize at entry pointPage/loader starts fetches before children
Chain dependent promisesOrders fetch receives userId from user promise
Component hierarchyProfile, Orders, Analytics — each own boundary
Selective / non-blocking loadRegions appear independently

Resources module pattern

tsx
// 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

tsx
// 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} />
}

Tip

Pass both promises from the page when dependencies are known upfront — Profile and Orders fetch in parallel, not in a waterfall triggered by child mount order.

Back to index


6. Full example: Account dashboard

Three independent regions — Profile, Orders, Analytics — each with Suspense + Error Boundary.

tsx
'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

tsx
import { Suspense, lazy } from 'react'
 
const HeavyChart = lazy(() => import('./HeavyChart'))
 
function ChartCard() {
  return (
    <Suspense fallback={<SectionSkeleton height="h-64" />}>
      <HeavyChart />
    </Suspense>
  )
}

Performance

Analytics often loads slowest — isolating it in its own boundary lets Profile and Orders appear first, improving perceived performance even when total bytes are unchanged.

Back to index


7. Benefits

BenefitOutcome
Cleaner, leaner codeNo if (loading) return … in every component
Faster perceived performanceShell + fast regions paint before slow data
Organized data dependenciesResources folder maps data to features explicitly
Improved developer experienceDeclarative mental model for async UI
Streaming (SSR)Server sends HTML incrementally per boundary
Parallel fetchingEntry-point promises avoid waterfalls

Before vs after — line count mindset

tsx
// 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)

Note

In Next.js App Router, loading.tsx is a route-level Suspense boundary — nested <Suspense> inside the page splits streaming further. See framework docs for Server Component + Suspense data patterns.

Back to index


8. Best practices & pitfalls

Practice / pitfallGuidance
New promise every renderCache in resources module by key
One giant Suspense boundarySplit per widget — avoid blocking whole page
Suspense without Error BoundaryRejected promises need boundary wrapper
use() in leaf without providerEnsure ancestor Suspense exists
TanStack Query + SuspenseUse useSuspenseQuery with boundaries
Client-only for use()Mark 'use client' where use(promise) runs

Next.js route loading boundary

tsx
// 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>
  )
}

Warning

Suspense is not a replacement for mutation loading states — form submits and button actions still need local isPending or useTransition. Suspense covers read paths (initial data, lazy imports), not every async operation.

Warning

Do not wrap every component in Suspense preemptively — each boundary adds coordination overhead. Boundary at async leaves (data components, lazy() imports) and route segments.

Tip

Production checklist

  • Resources folder with cached promises
  • Start fetches at page entry — parallelize independent data
  • Error Boundary outside Suspense for async regions
  • Skeleton fallbacks matched to layout
  • Granular boundaries — Profile / Orders / Analytics separate
  • Invalidate cache on Error Boundary retry
  • Next.js loading.tsx for route-level fallback
  • TanStack Query useSuspenseQuery when using Query cache
  • Keep mutations outside Suspense — use Actions or mutation state

Back to index


Summary

PieceRole
ResourcesCached promises — fetch once, read many
use(promise)Suspend until resolved
<Suspense>Declarative loading fallback
Error BoundaryDeclarative failure fallback
Entry-point fetchParallel 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.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime