React Suspense + Next.js streaming makes slow pages feel instant
I had a product page that took 2.8 seconds because it fetched three separate data sources sequentially. The user saw a blank screen for nearly 3 seconds before anything appeared. With Suspense boundaries and Next.js streaming, the page skeleton appears in 80ms and sections pop in as their data arrives. The total time to complete is the same — but the perceived performance is dramatically better.
The streaming model
When Next.js encounters a Suspense boundary around a slow async Server Component, it:
- Streams the HTML up to the boundary immediately
- Renders the
fallbackskeleton in place of the slow component - When the async component resolves, streams its HTML and replaces the skeleton
This requires HTTP/1.1 chunked transfer or HTTP/2 — both standard in modern deployments.
Before: sequential loading
// Without Suspense — everything loads together or nothing shows
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id); // 200ms
const reviews = await getReviews(params.id); // 800ms
const related = await getRelatedProducts(params.id); // 1200ms
// Total: 2200ms sequential wait before any HTML is sent
return (
);
}
After: parallel streaming with Suspense
import { Suspense } from 'react';
// Separate async components for each data source
async function ProductInfo({ id }: { id: string }) {
const product = await getProduct(id); // 200ms
return ...;
}
async function ReviewSection({ id }: { id: string }) {
const reviews = await getReviews(id); // 800ms
return ...;
}
async function RelatedProducts({ id }: { id: string }) {
const related = await getRelatedProducts(id); // 1200ms
return ...;
}
// Page component — starts all three fetches in parallel
export default function ProductPage({ params }: { params: { id: string } }) {
return (
}>
}>
}>
);
}
t=0ms : HTML starts streaming, all skeletons visible t=200ms : ProductInfo slot fills with real product data t=800ms : Reviews slot fills with reviews t=1200ms : Related products slot fills Total: 1200ms (was 2200ms — all fetches now parallel)
Building good skeleton components
// Match the exact layout of the real component to avoid layout shift
function ProductInfoSkeleton() {
return (
);
}
// Use the same CSS classes as the real component
// so the layout does not shift when the real content loads
Error boundaries with Suspense
'use client';
import { ErrorBoundary } from 'react-error-boundary';
// Wrap Suspense in ErrorBoundary for resilient sections
function ProductSection({ id }: { id: string }) {
return (
Failed to load product details
When NOT to use Suspense boundaries
Do not wrap every component in Suspense — it adds complexity and causes layout shift if the skeleton does not match the real component perfectly. Use Suspense boundaries when:
- The component's data load time is noticeably different from sibling components
- The component is below the fold (users will not see the skeleton anyway)
- Failure of this section should not block the rest of the page
For fast components (under 200ms), a single Suspense boundary around the whole page is better than granular boundaries that cause multiple layout shifts.