perf hacks
RenderingStreaming

Streaming & Suspense

Progressive rendering with React Suspense and streaming SSR for faster perceived performance

STREAMING vs TRADITIONAL SSRTraditionalWaiting for ALL data...ResponseStreamingShellFast dataMedium dataSlow dataFirst paint!User sees content immediatelyProgressive enhancement as data arrives

Streaming is fully supported in the App Router. Pages Router has limited streaming support through getServerSideProps with headers.

Streaming Timeline

Sequence diagram showing how React Streaming SSR with Suspense progressively renders content to the browser

Streaming SSR allows you to progressively render and send HTML to the client, improving Time to First Byte (TTFB) and First Contentful Paint (FCP) even when some data is slow to load.

Basic Streaming with Suspense

app/page.tsx
import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      {/* Shell renders immediately */}
      <Header />
      <Navigation />

      {/* Streams when data is ready */}
      <Suspense fallback={<ProductsSkeleton />}>
        <ProductList />
      </Suspense>

      {/* Can stream independently */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations />
      </Suspense>

      <Footer />
    </div>
  );
}

// This component fetches data
async function ProductList() {
  const products = await fetchProducts(); // 500ms
  return <ProductGrid products={products} />;
}

Nested Suspense Boundaries

// Parallel streaming - both load independently
export default function Page() {
  return (
    <div className="grid grid-cols-2 gap-4">
      <Suspense fallback={<LeftColumnSkeleton />}>
        <LeftColumn /> {/* 300ms */}
      </Suspense>

      <Suspense fallback={<RightColumnSkeleton />}>
        <RightColumn /> {/* 800ms */}
      </Suspense>
    </div>
  );
}

// Sequential streaming - child waits for parent
export default function Page() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <MainContent>
        <Suspense fallback={<DetailsSkeleton />}>
          <Details /> {/* Starts after MainContent */}
        </Suspense>
      </MainContent>
    </Suspense>
  );
}

Loading UI Patterns

Skeleton Components

components/skeletons.tsx
export function ProductCardSkeleton() {
  return (
    <div className="animate-pulse">
      <div className="h-48 bg-gray-200 rounded-lg" />
      <div className="mt-4 space-y-2">
        <div className="h-4 bg-gray-200 rounded w-3/4" />
        <div className="h-4 bg-gray-200 rounded w-1/2" />
      </div>
    </div>
  );
}

export function ProductGridSkeleton({ count = 6 }) {
  return (
    <div className="grid grid-cols-3 gap-4">
      {Array.from({ length: count }).map((_, i) => (
        <ProductCardSkeleton key={i} />
      ))}
    </div>
  );
}

Matching Skeleton Dimensions

// Prevent CLS by matching exact dimensions
function ArticleSkeleton() {
  return (
    <article className="max-w-2xl">
      {/* Match title height */}
      <div className="h-10 bg-gray-200 rounded w-4/5 mb-4" />

      {/* Match meta height */}
      <div className="h-5 bg-gray-200 rounded w-1/3 mb-8" />

      {/* Match paragraph heights */}
      <div className="space-y-3">
        <div className="h-4 bg-gray-200 rounded" />
        <div className="h-4 bg-gray-200 rounded" />
        <div className="h-4 bg-gray-200 rounded w-5/6" />
      </div>
    </article>
  );
}

Streaming Strategies

Shell Pattern

app/layout.tsx
export default function Layout({ children }) {
  return (
    <html>
      <body>
        {/* Static - renders immediately */}
        <Header />
        <Sidebar />

        {/* Dynamic - streams */}
        <main>{children}</main>

        {/* Static */}
        <Footer />
      </body>
    </html>
  );
}

Progressive Enhancement

// Show basic content first, enhance with details
export default function ProductPage({ params }) {
  return (
    <>
      {/* Critical info - streams first */}
      <Suspense fallback={<ProductHeaderSkeleton />}>
        <ProductHeader id={params.id} />
      </Suspense>

      {/* Secondary content */}
      <Suspense fallback={<ProductDetailsSkeleton />}>
        <ProductDetails id={params.id} />
      </Suspense>

      {/* Non-critical - can be slow */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={params.id} />
      </Suspense>
    </>
  );
}

Parallel Data Fetching

// Fetch data in parallel, stream as each resolves
async function Dashboard() {
  // Start all fetches simultaneously
  const statsPromise = fetchStats();
  const chartsPromise = fetchCharts();
  const recentPromise = fetchRecent();

  return (
    <div className="grid grid-cols-3 gap-4">
      <Suspense fallback={<StatsSkeleton />}>
        <Stats dataPromise={statsPromise} />
      </Suspense>

      <Suspense fallback={<ChartsSkeleton />}>
        <Charts dataPromise={chartsPromise} />
      </Suspense>

      <Suspense fallback={<RecentSkeleton />}>
        <Recent dataPromise={recentPromise} />
      </Suspense>
    </div>
  );
}

// Component that awaits the promise
async function Stats({ dataPromise }) {
  const stats = await dataPromise;
  return <StatsDisplay data={stats} />;
}

Error Handling with Streaming

app/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="p-4 bg-red-50 rounded-lg">
      <h2>Something went wrong!</h2>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

// Component-level error handling
import { ErrorBoundary } from 'react-error-boundary';

function Page() {
  return (
    <ErrorBoundary fallback={<ProductError />}>
      <Suspense fallback={<ProductSkeleton />}>
        <Products />
      </Suspense>
    </ErrorBoundary>
  );
}

Performance Impact

MetricWithout StreamingWith Streaming
TTFBWait for all dataImmediate shell
FCPAfter all dataAfter shell
LCPAfter all dataWhen LCP element streams
TTIAfter hydrationProgressive

Best Practices

Suspense Boundary Placement

Place boundaries around slow data fetches Wrap async components that fetch data.

Keep boundaries close to async components Don't wrap large trees unnecessarily.

Don't wrap synchronous components Only async components benefit from Suspense.

Consider user experience flow Critical content should stream first.

Skeleton Design

Match dimensions of real content Prevents CLS when content loads.

Use subtle animations (pulse, shimmer) Shows activity without being distracting.

Include key visual elements Skeleton should resemble final layout.

Test on slow connections Skeletons may be visible for longer than expected.

Data Fetching

Start fetches as early as possible Initiate requests at the top of the component tree.

Use parallel fetching when independent Promise.all for unrelated data.

Consider data dependencies Sequential fetching when data depends on previous results.

Cache where appropriate Use React cache() or Next.js data cache.

When to Use Streaming

ScenarioUse Streaming?
Slow database queriesYes
Third-party API callsYes
User-specific contentYes
Static contentNo (use SSG)
Real-time dataConsider (or use client)

On this page