Streaming and Suspense enable progressive rendering—showing parts of your page immediately while others load. Instead of waiting for all data before showing anything, stream HTML to the browser as it's ready. Use React Suspense boundaries to show loading states for async components. Master streaming and create fluid, responsive user experiences with instant feedback!
What is Streaming?
Traditional Server Rendering:
- Server fetches ALL data
- Server renders COMPLETE HTML
- Server sends HTML to browser
- Browser shows page (all at once)
- Total wait time: Sum of all data fetching + rendering
Streaming Server Rendering:
- Server starts rendering immediately
- Server sends HTML chunks as they're ready
- Browser shows content progressively
- Slower parts load in their Suspense boundaries
- First content visible: Almost immediately!
Benefits of Streaming
- Faster Time to First Byte (TTFB): Browser receives HTML sooner
- Progressive Enhancement: Show important content first
- Better Perceived Performance: Users see content loading
- Non-Blocking: Slow data doesn't block fast content
- SEO-Friendly: Search engines see content immediately
React Suspense Basics
Simple Suspense Boundary
import { Suspense } from 'react';
// Async Server Component
async function UserProfile() {
// Simulate slow data fetching
const user = await fetch('https://api.example.com/user').then(res => res.json());
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// Loading fallback
function UserProfileSkeleton() {
return (
<div className="animate-pulse">
<div className="h-8 w-48 bg-gray-200 rounded mb-2"></div>
<div className="h-4 w-32 bg-gray-200 rounded"></div>
</div>
);
}
// Page with Suspense
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* Suspense boundary */}
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile />
</Suspense>
</div>
);
}
// ✅ Page renders immediately
// ✅ Shows skeleton while UserProfile loads
// ✅ UserProfile streams in when readyMultiple Suspense Boundaries
import { Suspense } from 'react';
async function UserProfile() {
const user = await fetch('/api/user', { cache: 'no-store' }).then(r => r.json());
return <div>User: {user.name}</div>;
}
async function RecentActivity() {
// This takes longer
await new Promise(resolve => setTimeout(resolve, 2000));
const activity = await fetch('/api/activity').then(r => r.json());
return <div>Activity: {activity.count} items</div>;
}
async function Statistics() {
const stats = await fetch('/api/stats').then(r => r.json());
return <div>Stats: {stats.total}</div>;
}
function LoadingSkeleton() {
return <div className="animate-pulse h-20 bg-gray-200 rounded"></div>;
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* Each component loads independently */}
<Suspense fallback={<LoadingSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<LoadingSkeleton />}>
<RecentActivity />
</Suspense>
<Suspense fallback={<LoadingSkeleton />}>
<Statistics />
</Suspense>
</div>
);
}
// ✅ Page shows immediately
// ✅ Each section loads independently
// ✅ Fast sections don't wait for slow ones
// ✅ Progressive loading experienceNested Suspense Boundaries
import { Suspense } from 'react';
async function Header() {
const data = await fetch('/api/header').then(r => r.json());
return <header>{data.title}</header>;
}
async function Sidebar() {
const data = await fetch('/api/sidebar').then(r => r.json());
return <aside>{data.content}</aside>;
}
async function MainContent() {
const data = await fetch('/api/content').then(r => r.json());
return <main>{data.body}</main>;
}
export default function Page() {
return (
<div>
{/* Outer boundary for entire page */}
<Suspense fallback={<div>Loading page...</div>}>
{/* Inner boundary for header */}
<Suspense fallback={<div>Loading header...</div>}>
<Header />
</Suspense>
<div className="flex">
{/* Inner boundary for sidebar */}
<Suspense fallback={<div>Loading sidebar...</div>}>
<Sidebar />
</Suspense>
{/* Inner boundary for main content */}
<Suspense fallback={<div>Loading content...</div>}>
<MainContent />
</Suspense>
</div>
</Suspense>
</div>
);
}
// ✅ Nested boundaries
// ✅ Granular loading states
// ✅ Each section independentLoading UI Patterns
Skeleton Loaders
export function CardSkeleton() {
return (
<div className="border rounded-lg p-6 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
);
}
export function ListSkeleton({ count = 3 }: { count?: number }) {
return (
<div className="space-y-4">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center space-x-4 animate-pulse">
<div className="h-12 w-12 bg-gray-200 rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
))}
</div>
);
}
export function TableSkeleton() {
return (
<div className="animate-pulse">
<div className="h-12 bg-gray-200 rounded mb-4"></div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 bg-gray-100 rounded mb-2"></div>
))}
</div>
);
}
// ✅ Reusable skeleton components
// ✅ Match layout of actual content
// ✅ Smooth loading experienceUsing Skeleton Loaders
import { Suspense } from 'react';
import { CardSkeleton, ListSkeleton } from '@/components/LoadingSkeleton';
async function UserCard() {
const user = await fetchUser();
return (
<div className="border rounded-lg p-6">
<h2 className="text-xl font-bold">{user.name}</h2>
<p className="text-gray-600">{user.email}</p>
<p className="text-sm">{user.bio}</p>
</div>
);
}
async function ActivityList() {
const activities = await fetchActivities();
return (
<div className="space-y-4">
{activities.map(activity => (
<div key={activity.id} className="flex items-center space-x-4">
<img
src={activity.avatar}
alt={activity.user}
className="h-12 w-12 rounded-full"
/>
<div>
<p className="font-medium">{activity.user}</p>
<p className="text-sm text-gray-600">{activity.action}</p>
</div>
</div>
))}
</div>
);
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<CardSkeleton />}>
<UserCard />
</Suspense>
<Suspense fallback={<ListSkeleton count={5} />}>
<ActivityList />
</Suspense>
</div>
);
}
// ✅ Skeleton matches actual content layout
// ✅ Smooth visual transition
// ✅ Better UX than spinnersSpinner Loaders
export function Spinner({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12',
};
return (
<div className="flex items-center justify-center">
<div
className={`${sizeClasses[size]} border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin`}
/>
</div>
);
}
export function SpinnerWithText({ text = 'Loading...' }: { text?: string }) {
return (
<div className="flex flex-col items-center justify-center py-12">
<Spinner />
<p className="mt-4 text-gray-600">{text}</p>
</div>
);
}
// Use for full-page or section loading
export function PageSpinner() {
return (
<div className="min-h-screen flex items-center justify-center">
<SpinnerWithText text="Loading page..." />
</div>
);
}
// ✅ Reusable spinner components
// ✅ Different sizes
// ✅ Optional textRoute-Level Loading UI
loading.tsx File
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-48 mb-8"></div>
<div className="grid grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="border rounded-lg p-6">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
))}
</div>
</div>
);
}
// ✅ Automatic loading UI for route
// ✅ Shows while page.tsx loads
// ✅ Wraps entire page in Suspense automaticallyHow loading.tsx Works
// File structure:
app/
dashboard/
loading.tsx // Loading UI
page.tsx // Page component
// Next.js automatically creates:
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
// ✅ No manual Suspense needed
// ✅ Entire route wrapped automatically
// ✅ loading.tsx applies to page.tsx and all nested routesNested Loading States
// File structure:
app/
dashboard/
loading.tsx // Dashboard loading
page.tsx // Dashboard page
settings/
loading.tsx // Settings loading (more specific)
page.tsx // Settings page
// Navigation to /dashboard → Shows dashboard/loading.tsx
// Navigation to /dashboard/settings → Shows dashboard/settings/loading.tsx
// ✅ More specific loading.tsx overrides parent
// ✅ Granular control over loading statesStreaming Patterns
Parallel Data Fetching with Suspense
import { Suspense } from 'react';
// These fetch in parallel
async function UserStats() {
const stats = await fetch('/api/stats').then(r => r.json());
return <div>Total: {stats.total}</div>;
}
async function RecentOrders() {
const orders = await fetch('/api/orders').then(r => r.json());
return <div>Orders: {orders.length}</div>;
}
async function Revenue() {
const revenue = await fetch('/api/revenue').then(r => r.json());
return <div>Revenue: ${revenue.amount}</div>;
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<div className="grid grid-cols-3 gap-6">
{/* All three components fetch in parallel */}
<Suspense fallback={<LoadingSkeleton />}>
<UserStats />
</Suspense>
<Suspense fallback={<LoadingSkeleton />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<LoadingSkeleton />}>
<Revenue />
</Suspense>
</div>
</div>
);
}
// ✅ Three API calls happen simultaneously
// ✅ Each component streams when ready
// ✅ No waterfall - all parallelPreloading Pattern
import { Suspense } from 'react';
// Preload function (starts fetch immediately)
function preloadUser() {
return fetch('/api/user').then(r => r.json());
}
function preloadStats() {
return fetch('/api/stats').then(r => r.json());
}
async function UserProfile({ userPromise }: { userPromise: Promise<any> }) {
// Wait for preloaded data
const user = await userPromise;
return <div>{user.name}</div>;
}
async function Stats({ statsPromise }: { statsPromise: Promise<any> }) {
const stats = await statsPromise;
return <div>{stats.total}</div>;
}
export default function DashboardPage() {
// Start fetching immediately (before Suspense)
const userPromise = preloadUser();
const statsPromise = preloadStats();
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading user...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
<Suspense fallback={<div>Loading stats...</div>}>
<Stats statsPromise={statsPromise} />
</Suspense>
</div>
);
}
// ✅ Fetches start immediately
// ✅ No waiting for Suspense
// ✅ Faster data loadingGrouping Slow Operations
import { Suspense } from 'react';
// Fast components (no Suspense needed)
async function Header() {
const data = await fetch('/api/header', { cache: 'force-cache' }).then(r => r.json());
return <header>{data.title}</header>;
}
async function Navigation() {
const items = await fetch('/api/nav', { cache: 'force-cache' }).then(r => r.json());
return <nav>{items.map(i => <a key={i.id} href={i.href}>{i.label}</a>)}</nav>;
}
// Slow component (needs Suspense)
async function DynamicContent() {
// This takes 2+ seconds
const data = await fetch('/api/content', { cache: 'no-store' }).then(r => r.json());
return <main>{data.body}</main>;
}
export default function Page() {
return (
<div>
{/* Fast content renders immediately */}
<Header />
<Navigation />
{/* Only slow content in Suspense */}
<Suspense fallback={<div>Loading content...</div>}>
<DynamicContent />
</Suspense>
</div>
);
}
// ✅ Fast content shows immediately
// ✅ Only slow content suspended
// ✅ Optimal user experienceStreaming and Suspense Structure
Organization of components with Suspense boundaries
Select a file or folder to see details
Advanced Streaming Patterns
Conditional Suspense
import { Suspense } from 'react';
async function PremiumFeature() {
const data = await fetch('/api/premium').then(r => r.json());
return <div>Premium Content: {data.content}</div>;
}
export default async function DashboardPage() {
// Check user status (fast)
const user = await fetch('/api/user', { cache: 'force-cache' }).then(r => r.json());
return (
<div>
<h1>Dashboard</h1>
<p>Welcome, {user.name}!</p>
{/* Conditionally show Suspense */}
{user.isPremium ? (
<Suspense fallback={<div>Loading premium features...</div>}>
<PremiumFeature />
</Suspense>
) : (
<div>Upgrade to premium for more features!</div>
)}
</div>
);
}
// ✅ Check conditions before Suspense
// ✅ Only fetch if needed
// ✅ Better performanceError Boundaries with Suspense
'use client';
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="text-red-600">
Something went wrong. Please try again.
</div>
);
}
return this.props.children;
}
}import { Suspense } from 'react';
import { ErrorBoundary } from '@/components/ErrorBoundary';
async function RiskyComponent() {
const data = await fetch('/api/risky').then(r => {
if (!r.ok) throw new Error('Failed to fetch');
return r.json();
});
return <div>{data.content}</div>;
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* Error boundary + Suspense */}
<ErrorBoundary fallback={<div>Failed to load. Try refreshing.</div>}>
<Suspense fallback={<div>Loading...</div>}>
<RiskyComponent />
</Suspense>
</ErrorBoundary>
</div>
);
}
// ✅ Handles errors gracefully
// ✅ Shows error UI instead of crashing
// ✅ Suspense for loading, ErrorBoundary for errorsStreaming with Dynamic Routes
import { Suspense } from 'react';
async function Post({ id }: { id: string }) {
const post = await fetch(`/api/posts/${id}`).then(r => r.json());
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
async function Comments({ postId }: { postId: string }) {
// Slower query
const comments = await fetch(`/api/posts/${postId}/comments`).then(r => r.json());
return (
<div>
<h2>Comments ({comments.length})</h2>
{comments.map(c => (
<div key={c.id}>{c.text}</div>
))}
</div>
);
}
async function RelatedPosts({ postId }: { postId: string }) {
const related = await fetch(`/api/posts/${postId}/related`).then(r => r.json());
return (
<div>
<h2>Related Posts</h2>
{related.map(p => (
<a key={p.id} href={`/posts/${p.id}`}>{p.title}</a>
))}
</div>
);
}
export default function PostPage({ params }: { params: { id: string } }) {
return (
<div>
{/* Main post (fast) */}
<Suspense fallback={<div>Loading post...</div>}>
<Post id={params.id} />
</Suspense>
{/* Comments (slower) */}
<Suspense fallback={<div>Loading comments...</div>}>
<Comments postId={params.id} />
</Suspense>
{/* Related posts (slowest) */}
<Suspense fallback={<div>Loading related posts...</div>}>
<RelatedPosts postId={params.id} />
</Suspense>
</div>
);
}
// ✅ Post content shows first
// ✅ Comments stream in next
// ✅ Related posts last
// ✅ Progressive experienceStreaming Best Practices
1. Use Multiple Suspense Boundaries
// ✅ GOOD: Multiple boundaries
<div>
<Suspense fallback={<UserSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
// ❌ BAD: Single boundary for everything
<Suspense fallback={<div>Loading...</div>}>
<UserProfile />
<RecentActivity />
<Statistics />
<Comments />
</Suspense>
// Single boundary waits for ALL components
// Use multiple for independent loading2. Match Skeleton to Actual Content
// ✅ GOOD: Skeleton matches layout
function UserCardSkeleton() {
return (
<div className="border rounded-lg p-6">
<div className="h-12 w-12 bg-gray-200 rounded-full mb-4"></div>
<div className="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
);
}
// ❌ BAD: Generic spinner
function Loading() {
return <div className="spinner"></div>;
}
// Skeleton provides layout stability
// Prevents content shift when loading completes3. Stream Critical Content First
// ✅ GOOD: Critical content outside Suspense
export default function Page() {
return (
<div>
{/* Critical: No Suspense */}
<Header />
<Navigation />
{/* Non-critical: In Suspense */}
<Suspense fallback={<Skeleton />}>
<Recommendations />
</Suspense>
</div>
);
}
// Show important content immediately
// Suspend less important parts4. Avoid Suspense for Fast Operations
// ✅ GOOD: Only suspend slow operations
async function FastData() {
const data = await fetch('/api/fast', {
cache: 'force-cache', // Instant
}).then(r => r.json());
return <div>{data.content}</div>;
}
async function SlowData() {
const data = await fetch('/api/slow', {
cache: 'no-store', // 2+ seconds
}).then(r => r.json());
return <div>{data.content}</div>;
}
export default function Page() {
return (
<div>
{/* No Suspense for fast data */}
<FastData />
{/* Suspense for slow data */}
<Suspense fallback={<Skeleton />}>
<SlowData />
</Suspense>
</div>
);
}
// Don't add Suspense overhead for fast operations5. Preload Data When Possible
// ✅ GOOD: Start fetching early
export default function Page() {
// Start fetching immediately
const dataPromise = fetch('/api/data').then(r => r.json());
return (
<div>
<Suspense fallback={<Skeleton />}>
<AsyncComponent dataPromise={dataPromise} />
</Suspense>
</div>
);
}
// Fetch starts before Suspense
// Reduces wait timeKey Takeaways
- Streaming - send HTML progressively as it's ready
- Suspense boundaries - show fallback while async content loads
- loading.tsx - automatic route-level loading UI
- Multiple boundaries - independent loading per component
- Skeleton loaders - match actual content layout
- Parallel fetching - Suspense enables parallel data loading
- Progressive rendering - fast content first, slow content streams
- Better UX - users see content loading, not blank pages
What's Next?
You've mastered streaming and Suspense! Next, we'll explore Not Found and Global Error Pages—creating custom 404 pages with not-found.tsx, building global error handlers, implementing error recovery, and handling different error scenarios gracefully. You'll complete your application's error handling strategy!
We'll cover not-found.tsx, global-error.tsx, error.tsx, and complete error handling patterns.
⚡ Performance Tip
Use Suspense strategically: wrap slow operations but not fast ones, use multiple boundaries for independent loading, match skeleton loaders to actual content layout, and stream critical content first. Streaming dramatically improves perceived performance!