React Suspense Patterns: Boundaries, Error Recovery, Streaming SSR, and Data Fetching
Master React Suspense in production: compose Suspense and error boundaries, stream HTML with React 18 streaming SSR, integrate with React Query and Next.js App Router, and avoid common pitfalls like waterfall fetching and missing error boundaries.
React Suspense lets components declare that they're "waiting" for something โ data, code, or an image โ and the nearest <Suspense> boundary renders a fallback until they're ready. The result: loading states become declarative, collocated with the component that needs the data, rather than scattered if (loading) return <Spinner /> checks.
In Next.js App Router, Suspense also enables streaming: the server sends HTML in chunks as each Suspense boundary resolves, so users see content progressively rather than waiting for the slowest data fetch.
Suspense Boundary Placement
The placement of Suspense boundaries determines the granularity of loading states:
// src/app/dashboard/page.tsx
// Option A: One boundary wraps everything
// โ Entire page shows spinner until ALL data loads (bad for perceived performance)
export default function DashboardPage() {
return (
<Suspense fallback={<PageSpinner />}>
<StatsSection /> {/* Fast: 50ms */}
<ProjectsTable /> {/* Slow: 800ms */}
<ActivityFeed /> {/* Medium: 300ms */}
</Suspense>
);
}
// Option B: Granular boundaries per section (recommended)
// โ Each section shows content as soon as its data is ready
export default function DashboardPage() {
return (
<div className="grid grid-cols-12 gap-6">
{/* Fast content shows immediately */}
<Suspense fallback={<StatsSkeleton />}>
<StatsSection />
</Suspense>
{/* Slow content doesn't block fast content */}
<Suspense fallback={<TableSkeleton rows={10} />}>
<ProjectsTable />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}
Error Boundaries with Suspense
Every Suspense boundary should have a corresponding error boundary. Without it, thrown errors bubble to the nearest React error boundary โ which may be at the root, crashing the whole page:
// src/components/AsyncBoundary.tsx
"use client";
import { Component, ErrorInfo, ReactNode, Suspense } from "react";
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
// Class component โ error boundaries must be class components
class ErrorBoundary extends Component<
{
fallback: ReactNode | ((error: Error, reset: () => void) => ReactNode);
children: ReactNode;
},
ErrorBoundaryState
> {
state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Log to error tracking (Sentry, etc.)
console.error("AsyncBoundary caught error:", error, info.componentStack);
}
reset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError && this.state.error) {
const { fallback } = this.props;
return typeof fallback === "function"
? fallback(this.state.error, this.reset)
: fallback;
}
return this.props.children;
}
}
// Combined Suspense + Error boundary โ the pattern you'll use most
interface AsyncBoundaryProps {
loadingFallback: ReactNode;
errorFallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
children: ReactNode;
}
export function AsyncBoundary({
loadingFallback,
errorFallback,
children,
}: AsyncBoundaryProps) {
const defaultErrorFallback = (error: Error, reset: () => void) => (
<div className="flex flex-col items-center gap-2 p-4 text-center">
<p className="text-sm text-red-600">{error.message}</p>
<button
onClick={reset}
className="text-sm text-blue-600 underline hover:no-underline"
>
Try again
</button>
</div>
);
return (
<ErrorBoundary fallback={errorFallback ?? defaultErrorFallback}>
<Suspense fallback={loadingFallback}>{children}</Suspense>
</ErrorBoundary>
);
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Streaming SSR with Next.js App Router
In Next.js App Router, every async Server Component inside a <Suspense> boundary is streamed:
// src/app/product/[id]/page.tsx
// Demonstrates streaming: fast content first, slow content later
import { Suspense } from "react";
// Fast: static or cached data
async function ProductHeader({ productId }: { productId: string }) {
const product = await getProduct(productId); // Cached, fast
return (
<div>
<h1 className="text-3xl font-bold">{product.name}</h1>
<p className="text-2xl font-semibold">${product.price}</p>
</div>
);
}
// Slow: real-time inventory check (can't be cached)
async function InventoryBadge({ productId }: { productId: string }) {
const inventory = await checkInventory(productId); // Slow, uncached
return (
<span className={inventory.inStock ? "badge-green" : "badge-red"}>
{inventory.inStock ? `${inventory.count} in stock` : "Out of stock"}
</span>
);
}
// Slow: personalized recommendations
async function Recommendations({ productId, userId }: { productId: string; userId: string }) {
const recs = await getRecommendations(productId, userId); // Slow ML call
return (
<ul>
{recs.map((rec) => (
<li key={rec.id}>{rec.name}</li>
))}
</ul>
);
}
export default function ProductPage({
params,
}: {
params: { id: string };
}) {
const userId = getUserId(); // From cookies/session
return (
<main>
{/* Renders immediately โ no Suspense needed for fast content */}
<Suspense fallback={<ProductHeaderSkeleton />}>
<ProductHeader productId={params.id} />
</Suspense>
{/* Streams in when inventory check completes */}
<Suspense fallback={<div className="h-6 w-24 animate-pulse bg-gray-200 rounded" />}>
<InventoryBadge productId={params.id} />
</Suspense>
{/* Streams in last โ doesn't block the rest of the page */}
<section>
<h2>You might also like</h2>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={params.id} userId={userId} />
</Suspense>
</section>
</main>
);
}
Avoiding Waterfall Fetches
Waterfall = sequential fetches where each waits for the previous. The fix: parallel data fetching with Promise.all:
// src/app/dashboard/page.tsx
// BAD: Sequential โ total time = 150ms + 300ms + 500ms = 950ms
async function DashboardBad() {
const user = await getUser(); // 150ms
const projects = await getProjects(); // 300ms โ waits for user
const stats = await getStats(); // 500ms โ waits for projects
return <Dashboard user={user} projects={projects} stats={stats} />;
}
// GOOD: Parallel โ total time = max(150ms, 300ms, 500ms) = 500ms
async function DashboardGood() {
const [user, projects, stats] = await Promise.all([
getUser(),
getProjects(),
getStats(),
]);
return <Dashboard user={user} projects={projects} stats={stats} />;
}
// BETTER: Parallel + streaming โ each section appears as its data arrives
async function DashboardBest() {
// Initiate all fetches immediately (don't await yet)
// Pass the Promises to child components
const userPromise = getUser();
const projectsPromise = getProjects();
const statsPromise = getStats();
return (
<div>
<Suspense fallback={<UserSkeleton />}>
{/* React.use() unwraps the Promise inside the component */}
<UserSection userPromise={userPromise} />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<StatsSection statsPromise={statsPromise} />
</Suspense>
<Suspense fallback={<ProjectsSkeleton />}>
<ProjectsSection projectsPromise={projectsPromise} />
</Suspense>
</div>
);
}
// Child component using React.use() (React 19)
async function StatsSection({
statsPromise,
}: {
statsPromise: Promise<Stats>;
}) {
const stats = await statsPromise; // In Next.js App Router, just await directly
return <StatsGrid stats={stats} />;
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
React Query with Suspense
// src/hooks/useProjectsSuspense.ts
"use client";
import { useSuspenseQuery, useSuspenseInfiniteQuery } from "@tanstack/react-query";
// useSuspenseQuery: component suspends while loading, throws on error
// No need for isLoading/isError checks โ Suspense/ErrorBoundary handle them
export function ProjectsList() {
const { data } = useSuspenseQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
// staleTime: data is fresh for 60 seconds, no refetch needed
staleTime: 60_000,
});
// data is guaranteed to be defined here โ no null check needed
return (
<ul>
{data.projects.map((project) => (
<li key={project.id}>{project.name}</li>
))}
</ul>
);
}
// Wrap in boundaries at the call site:
export function ProjectsSection() {
return (
<AsyncBoundary
loadingFallback={<ProjectsSkeleton />}
errorFallback={(error, reset) => (
<div>
<p>Failed to load projects: {error.message}</p>
<button onClick={reset}>Retry</button>
</div>
)}
>
<ProjectsList />
</AsyncBoundary>
);
}
Deferred Transitions (Non-Urgent Updates)
// src/components/SearchResults.tsx
"use client";
import { useState, useTransition, Suspense } from "react";
// useTransition marks state updates as non-urgent
// The current UI stays interactive while the transition is pending
export function SearchPage() {
const [query, setQuery] = useState("");
const [deferredQuery, setDeferredQuery] = useState("");
const [isPending, startTransition] = useTransition();
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value); // Urgent: input stays responsive
startTransition(() => {
setDeferredQuery(value); // Non-urgent: results update can be deferred
});
}
return (
<div>
<input
value={query}
onChange={handleSearch}
placeholder="Search projects..."
className="w-full border px-3 py-2 rounded"
/>
{/* dim results while transition is pending โ not a full loading spinner */}
<div className={isPending ? "opacity-50 pointer-events-none" : ""}>
<Suspense fallback={<SearchSkeleton />}>
<SearchResults query={deferredQuery} />
</Suspense>
</div>
</div>
);
}
Common Suspense Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| No error boundary with Suspense | Thrown errors crash the whole page | Always pair <Suspense> with <ErrorBoundary> |
| One boundary for all content | Everything waits for the slowest fetch | Granular boundaries per section |
Fetching inside useEffect | No streaming benefit, waterfall risk | Fetch in Server Components or useSuspenseQuery |
| Awaiting sequentially in Server Component | Waterfall: total time = sum of all waits | Promise.all() or start promises before awaiting |
Missing key prop on dynamic Suspense | Old fallback shown for new content | Add key={id} to force boundary reset on navigation |
See Also
- Next.js App Router Patterns โ App Router architecture
- React Query Patterns โ server state with Suspense
- React Performance Optimization โ memo, transitions
- Next.js Caching Strategies โ data cache + Suspense
Working With Viprasol
React Suspense, when used correctly, dramatically improves perceived performance โ users see content progressively rather than waiting for the slowest query. Our React engineers design Suspense boundary trees that match your data dependencies, integrate with React Query and Next.js streaming, and ensure every boundary has proper error recovery.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.