React Suspense + Next.js streaming makes slow pages feel instant
← Back
April 4, 2026React6 min read

React Suspense + Next.js streaming makes slow pages feel instant

Published April 4, 20266 min read

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:

  1. Streams the HTML up to the boundary immediately
  2. Renders the fallback skeleton in place of the slow component
  3. 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

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

typescript
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

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

typescript
'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.

Share this
LinkedInX / TwitterWhatsAppEmail
← All Posts6 min read