A Practical Guide to React Suspense and Streaming SSR
Suspense isn't just for loading spinners. It fundamentally changes how you architect data fetching — streaming HTML to the browser before your slowest query finishes.
React Suspense has been "coming soon" for years. It's here now — and it changes everything about how you think about loading states.
This isn't a theory post. We'll build a real page with streaming SSR, progressive loading, and nested Suspense boundaries — the patterns Next.js App Router is built around.
The Old Way vs. The New Way
Old: Waterfall Loading
// Page loads → fetch all data → render entire page
export default function Dashboard() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState(null);
const [analytics, setAnalytics] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([fetchUser(), fetchPosts(), fetchAnalytics()])
.then(([u, p, a]) => {
setUser(u);
setPosts(p);
setAnalytics(a);
})
.finally(() => setLoading(false));
}, []);
if (loading) return <Spinner />;
// Entire page blocked until ALL data loads
return <>{/* ... */}</>;
}
Problems:
- Blank screen until every API call finishes
- Slowest endpoint dictates total load time
- If analytics takes 3 seconds, user sees nothing for 3 seconds
New: Streaming with Suspense
// Each section loads independently — fast parts show immediately
export default function Dashboard() {
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<RecentPosts />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics />
</Suspense>
</div>
);
}
Now each section loads independently. User info appears in 100ms. Posts in 200ms. Analytics in 3 seconds — but the user has been interacting with the page for 2.9 seconds already.
Part 1: Async Server Components
In Next.js App Router, server components can be async:
// This runs on the server — no useEffect, no useState
async function UserProfile() {
const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return (
<div className="flex items-center gap-4">
<img src={user.avatar} alt={user.name} className="h-12 w-12 rounded-full" />
<div>
<h2 className="font-bold">{user.name}</h2>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
</div>
);
}
No loading state management. No useEffect. The component is the data fetch. Suspense handles the loading boundary.
Part 2: Nested Suspense Boundaries
The power is in nesting — each boundary is independent:
// app/dashboard/page.tsx
import { Suspense } from "react";
export default function DashboardPage() {
return (
<main className="grid grid-cols-3 gap-6 p-8">
{/* Left column — loads fast */}
<div className="col-span-2 space-y-6">
<Suspense fallback={<div className="h-24 animate-pulse rounded-xl bg-gray-800" />}>
<WelcomeBanner />
</Suspense>
<Suspense fallback={<div className="h-64 animate-pulse rounded-xl bg-gray-800" />}>
<RecentActivity />
</Suspense>
</div>
{/* Right column — might be slow */}
<div className="space-y-6">
<Suspense fallback={<div className="h-40 animate-pulse rounded-xl bg-gray-800" />}>
<QuickStats />
</Suspense>
<Suspense fallback={<div className="h-96 animate-pulse rounded-xl bg-gray-800" />}>
<AnalyticsChart />
</Suspense>
</div>
</main>
);
}
Each Suspense boundary:
- Renders its fallback immediately (skeleton/shimmer)
- Streams the real content as soon as the async component resolves
- Replaces the fallback with the real content — no layout shift if sized correctly
Part 3: The loading.tsx Convention
Next.js App Router auto-wraps your page in Suspense:
app/
dashboard/
page.tsx ← your async page
loading.tsx ← automatic Suspense fallback
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<main className="grid grid-cols-3 gap-6 p-8">
<div className="col-span-2 space-y-6">
<div className="h-24 animate-pulse rounded-xl bg-gray-800" />
<div className="h-64 animate-pulse rounded-xl bg-gray-800" />
</div>
<div className="space-y-6">
<div className="h-40 animate-pulse rounded-xl bg-gray-800" />
<div className="h-96 animate-pulse rounded-xl bg-gray-800" />
</div>
</main>
);
}
This gives instant page transitions — the loading skeleton shows immediately while the page data streams in.
Part 4: Streaming SSR in Action
Here's what happens on the wire:
Browser request → Server starts rendering
Time 0ms: Server sends <html>, <head>, navigation, skeleton shells
Time 100ms: User data resolves → server streams UserProfile HTML chunk
Time 200ms: Posts resolve → server streams RecentActivity HTML chunk
Time 3000ms: Analytics resolves → server streams AnalyticsChart HTML chunk
Browser progressively hydrates each chunk as it arrives
The browser doesn't wait. It renders whatever HTML has arrived and keeps listening for more. This is streaming — the response is a continuous flow, not a single payload.
Part 5: Error Boundaries with Suspense
Pair Suspense with Error Boundaries for complete resilience:
"use client";
import { Component, type ReactNode } from "react";
type Props = {
children: ReactNode;
fallback: ReactNode;
};
type State = { hasError: boolean; error: Error | null };
class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// Usage:
function DashboardPage() {
return (
<ErrorBoundary fallback={<p>Analytics unavailable</p>}>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics />
</Suspense>
</ErrorBoundary>
);
}
If the analytics API is down, only that section shows an error. The rest of the dashboard works fine.
Exercises
Exercise 1: Staggered Loading Animation
Create a Suspense fallback that animates in sequence:
function StaggeredSkeleton({ count }: { count: number }) {
return (
<div className="space-y-3">
{Array.from({ length: count }, (_, i) => (
<div
key={i}
className="h-16 animate-pulse rounded-xl bg-gray-800"
style={{ animationDelay: `${i * 100}ms` }}
/>
))}
</div>
);
}
Exercise 2: Prefetching with use()
React 19's use() hook lets client components consume promises:
"use client";
import { use, Suspense } from "react";
function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
const comments = use(commentsPromise);
return (
<ul>
{comments.map((c) => (
<li key={c.id}>{c.text}</li>
))}
</ul>
);
}
// Parent starts the fetch, child consumes it:
function PostPage({ post }: { post: Post }) {
const commentsPromise = fetchComments(post.id);
return (
<article>
<h1>{post.title}</h1>
<Suspense fallback={<p>Loading comments...</p>}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
</article>
);
}
Exercise 3: Suspense-Aware Skeleton Components
Build a reusable skeleton system:
function Skeleton({ className }: { className?: string }) {
return (
<div
className={`animate-pulse rounded-lg bg-gray-800 ${className ?? ""}`}
/>
);
}
function CardSkeleton() {
return (
<div className="rounded-2xl border border-gray-800 p-6 space-y-4">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
);
}
Mental Model
Think of Suspense as progressive enhancement for data:
- Without Suspense: Everything loads or nothing loads
- With Suspense: Fast content appears immediately, slow content streams in
The server doesn't wait for your slowest query. It sends what it has and keeps the connection open for the rest. That's streaming SSR.
What's the slowest page in your app? Suspense could make it feel instant. Tell us at pmml.info@gmail.com.
PMML Engineering builds performant web applications, AI products, and real-time systems for teams that ship. Based in Accra, Ghana.