Next.js App Router Streaming Patterns

Modern SaaS dashboards must deliver visible content in under 200 ms while simultaneously resolving user-specific data that can take seconds to fetch. The Next.js App Router solves this tension through a chunked, stream-based rendering model built on React Server Components (RSC) and the React Flight Protocol. Instead of blocking the browser until the entire component tree resolves, the server serialises each completed subtree into a discrete Flight chunk and sends it over Transfer-Encoding: chunked the moment it is ready. The result is a page whose shell, navigation, and above-the-fold content appear immediately, while slower data-dependent panels stream in independently β€” all within the broader Framework-Specific Islands & Streaming SSR paradigm that eliminates the monolithic hydration waterfall.

Concept Definition & Scope

Next.js App Router streaming is the runtime orchestration layer that converts an async React Server Component tree into an ordered sequence of HTML and RSC payload chunks, each associated with an explicit hydration boundary that tells the client runtime exactly where to attach JavaScript and when.

In scope: RSC serialisation, <Suspense> boundary placement, loading.tsx route segments, use client directive semantics, parallel data fetching via fetch/cache, tag-based revalidation, and edge runtime configuration.

Out of scope: Client-side router transitions (these bypass SSR entirely), React Native streaming, and full-client rendering via create-next-app without the app/ directory.

This page focuses on the App Router’s streaming mechanics. For the broader question of when partial hydration makes more sense than full RSC streaming, see the comparison section below.


Streaming Architecture: How the Runtime Executes It

The following diagram shows the three-zone model the App Router uses: a static shell zone that serialises instantly, a streaming zone where <Suspense> boundaries hold open the HTTP connection, and a client hydration zone where use client components activate.

Next.js App Router Streaming Zones Diagram showing three zones: Server Shell (renders immediately), Streaming Zone (Suspense boundaries, async RSC), and Client Hydration Zone (use client components). Arrows show the React Flight Protocol carrying chunks from server to client over Transfer-Encoding: chunked. SERVER SHELL renders immediately <html> / <body> / <header> layout.tsx (RSC, static) loading.tsx fallback Chunk 1 β†’ browser in <100ms STREAMING ZONE <Suspense> boundaries Boundary A async MetricsPanel() Chunk 2 β†’ ~400ms Boundary B async ActivityFeed() Chunk 3 β†’ ~700ms React Flight Protocol Transfer-Encoding: chunked CLIENT HYDRATION 'use client' components InteractiveChart (client JS) FilterDropdown (client JS) hydrated after Chunk arrives; no full-page block

The key insight is that boundaries A and B resolve concurrently on the server β€” neither blocks the other. The client receives chunks in whichever order the server completes them, and React inserts each chunk into the correct DOM position using the <!--$--> / <!--/$--> Flight markers embedded in the initial HTML.


Technical Mechanics

RSC Serialisation and the Flight Protocol

When the server renders an async RSC, React does not wait for all promises to settle before writing to the HTTP response. Instead it:

  1. Serialises the synchronous portions of the tree into HTML and writes them immediately.
  2. Encodes each pending <Suspense> boundary’s fallback into the same initial chunk.
  3. As each async component resolves, serialises its output into a new Flight chunk and appends it to the open HTTP response, accompanied by a small inline <script> tag that tells the client runtime where to splice the new markup.

This means TTFB is determined solely by the synchronous shell, not by your slowest database query.

// app/dashboard/page.tsx
// RSC (no directive = server-only by default)
import { Suspense } from 'react';
import { fetchMetrics, fetchRecentActivity } from '@/lib/data';

// Async RSC: React Flight serialises this independently
async function MetricsPanel() {
  const metrics = await fetchMetrics(); // does NOT block the shell
  return (
    <div className="metrics-grid">
      {metrics.map(m => (
        <div key={m.id} className="metric-card">
          <span className="metric-value">{m.value}</span>
          <span className="metric-label">{m.label}</span>
        </div>
      ))}
    </div>
  );
}

async function ActivityFeed() {
  const activity = await fetchRecentActivity(); // parallel with MetricsPanel
  return (
    <ul className="activity-list">
      {activity.map(a => (
        <li key={a.id}>{a.description}</li>
      ))}
    </ul>
  );
}

export default function DashboardPage() {
  return (
    <main>
      {/* Shell: in the browser before either fetch resolves */}
      <h1>Dashboard Overview</h1>

      {/* Boundary A: streams its own Flight chunk when MetricsPanel settles */}
      <Suspense fallback={<div className="skeleton-panel" aria-busy="true" />}>
        <MetricsPanel />
      </Suspense>

      {/* Boundary B: streams independently β€” no waterfall between A and B */}
      <Suspense fallback={<div className="skeleton-list" aria-busy="true" />}>
        <ActivityFeed />
      </Suspense>
    </main>
  );
}

Each <Suspense> wrapper is an independent streaming boundary. When MetricsPanel resolves at 400 ms and ActivityFeed resolves at 700 ms, the browser receives and renders them in that order without any coordination overhead.


Inside the Flight Payload β€” Rows, References and Chunks The response is drawn as a vertical list of numbered rows. Server component output arrives as ready-made element rows. Client component boundaries arrive as module references naming a chunk and an export. Suspended subtrees arrive first as a placeholder row and later as a completion row that replaces it. On the right, the browser column shows which of those rows cause a network request for JavaScript. The response is a row stream, not a document FLIGHT ROWS, IN ARRIVAL ORDER 0 Β· server element tree β€” layout, headings, static copy 1 Β· client reference β†’ chunk "cart-widget", export default 2 Β· suspense placeholder for the reviews subtree 3 Β· more server output β€” footer, related links 4 Β· completion row for 2 β€” reviews resolved, replaces it WHAT THE BROWSER DOES paints β€” no JavaScript needed fetches one chunk, hydrates that subtree shows the fallback, reserves the space paints β€” still no JavaScript swaps in the subtree, no reflow above it

Comparison: Streaming Approaches Across Rendering Models

Dimension Next.js App Router (RSC) Astro Islands SvelteKit Islands Qwik Resumability
Streaming granularity Per <Suspense> boundary Build-time; no runtime streaming Per load() function per route Resumable β€” no hydration step
Client JS bundle Only use client subtrees Per island with client directive Per component with browser lifecycle Near-zero; serialised closures
Dynamic user data Excellent β€” runtime fetch + tag revalidation Limited β€” suited to static/semi-static Good β€” server load functions per route Excellent β€” reactive signals
DX complexity Medium β€” boundary placement requires care Low β€” explicit directives per island Low β€” clear server/client split High β€” new mental model
TTI on cold visit Fast β€” shell before data resolves Very fast β€” minimal JS Fast β€” selective hydration Very fast β€” no replay
Cache granularity Per-fetch tag revalidation Build-time per route Per load function N/A β€” no cache layer

Use the App Router’s streaming model when your pages carry significant runtime personalisation β€” logged-in dashboards, live feeds, user-specific recommendations. Prefer Astro Islands when the majority of the page is static and only a few widgets need JavaScript.


Step-by-Step Integration Pattern

Step 1 β€” Enable the App Router and Streaming Defaults

Next.js 13.4+ streams by default when you use the app/ directory. Confirm your next.config.ts:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  // Do NOT disable serverExternalPackages unless a specific npm package requires it β€”
  // disabling it forces some server logic into the client bundle, breaking RSC streaming.
  experimental: {},
};

export default nextConfig;

Step 2 β€” Set Route Segment Configs

Control caching and streaming behaviour per route using segment config exports:

// app/dashboard/page.tsx (top of file, before any imports)

// Force server-render on every request β€” required for fully personalised pages.
// Omit this on pages that can be partially cached.
export const dynamic = 'force-dynamic';

// OR use ISR: revalidate the streamed output every 60 seconds
// export const revalidate = 60;

// Target the Edge runtime for lower TTFB on global routes.
// Remove this if you use Node.js-only APIs (fs, crypto, etc.).
export const runtime = 'edge';

Step 3 β€” Add loading.tsx for Automatic Route-Level Suspense

Place a loading.tsx file alongside page.tsx. Next.js automatically wraps the segment in <Suspense> and renders loading.tsx as the fallback:

// app/dashboard/loading.tsx
// Rendered as the Suspense fallback while the async page.tsx resolves.
// Keep this lightweight β€” it is the first thing the user sees.
export default function DashboardSkeleton() {
  return (
    <div aria-busy="true" aria-label="Loading dashboard">
      <div className="skeleton-header" />
      <div className="skeleton-panel" />
      <div className="skeleton-list" />
    </div>
  );
}

Step 4 β€” Parallel Data Fetching with Request Memoisation

Use React’s cache() to deduplicate identical fetches across multiple RSC invocations in the same render pass. Initiate fetches in parallel by calling them before any await:

// lib/data.ts
import { cache } from 'react';

// cache() memoises within a single server request β€” two components calling
// getProduct('42') in the same render will share one network round-trip.
export const getProduct = cache(async (id: string) => {
  const res = await fetch(`/api/products/${id}`, {
    // next.tags lets you surgically invalidate this fetch later
    next: { tags: [`product-${id}`], revalidate: 3600 },
  });
  if (!res.ok) return null;
  return res.json() as Promise<Product>;
});
// app/products/[id]/page.tsx
import { getProduct } from '@/lib/data';
import { notFound } from 'next/navigation';

export async function generateStaticParams() {
  // Pre-render high-traffic product pages at build time;
  // unknown IDs will be server-rendered on demand and cached.
  return [{ id: '1' }, { id: '2' }, { id: '3' }];
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params; // params is async in Next.js 15+
  const product = await getProduct(id);
  if (!product) notFound();

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
    </article>
  );
}

Call revalidateTag('product-42') inside a Server Action to invalidate only the affected fetch without clearing the entire route cache.

Step 5 β€” Enforce the use client Boundary

Mark interactive subtrees with 'use client' and pass only JSON-serialisable props across the boundary:

// components/InteractiveChart.tsx
'use client'; // Marks the hydration entry point for this subtree

import { useState, useEffect } from 'react';

interface ChartData {
  labels: string[];
  values: number[];
}

export default function InteractiveChart({
  initialData,
}: {
  initialData: ChartData; // Must be JSON-serialisable β€” no Date, Map, Set, or class instances
}) {
  const [data, setData] = useState(initialData);
  // isHydrated guards against a flash of unstyled content while streaming completes
  const [isHydrated, setIsHydrated] = useState(false);

  useEffect(() => {
    setIsHydrated(true);
    // Safe to attach WebSocket or polling here β€” runs only in the browser
  }, []);

  if (!isHydrated) {
    // Matches the server-rendered placeholder exactly to avoid layout shift (CLS)
    return <div className="chart-placeholder" aria-label="Loading chart" />;
  }

  return (
    <canvas
      aria-label="Interactive metrics chart"
      data-values={JSON.stringify(data.values)}
    />
  );
}
// app/dashboard/page.tsx β€” consuming the client component from an RSC
import { Suspense } from 'react';
import InteractiveChart from '@/components/InteractiveChart';
import { fetchChartData } from '@/lib/data';

async function ChartSection() {
  const chartData = await fetchChartData();
  // chartData is serialised here on the server; only JSON crosses the boundary
  return <InteractiveChart initialData={chartData} />;
}

export default function DashboardPage() {
  return (
    <main>
      <Suspense fallback={<div className="chart-placeholder" aria-busy="true" />}>
        <ChartSection />
      </Suspense>
    </main>
  );
}

Measurement & Validation

Confirm that streaming is working correctly before shipping. The cross-boundary prop passing checks below complement the streaming-specific ones here.

Network Tab: Verify Chunked Delivery

  1. Open Chrome DevTools β†’ Network tab.
  2. Disable cache and set throttling to Slow 4G.
  3. Navigate to your route. Filter by Doc.
  4. Select the document request and open the Headers pane. Confirm Transfer-Encoding: chunked is present.
  5. Open the Response pane. Scroll down β€” you should see <!--$--> and <!--/$--> comment markers delimiting each streamed <Suspense> boundary, with resolved markup appearing below each pair of markers as data arrives.

Performance Tab: Hydration Timeline

// Add to _app or layout.tsx during development only
if (typeof window !== 'undefined') {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.name.startsWith('react-')) {
        console.log(`[Hydration] ${entry.name}: ${entry.duration.toFixed(1)}ms`);
      }
    }
  });
  observer.observe({ type: 'measure', buffered: true });
}

Target benchmarks for production routes:

Metric Target Measurement
TTFB < 200 ms Network waterfall / performance.getEntriesByType('navigation')[0].responseStart
Hydration delta (per boundary) < 100 ms React DevTools Profiler β†’ Commit chart
Main-thread blocking (TBT) < 200 ms total Lighthouse CI --preset=desktop
CLS < 0.1 Skeleton dimensions must match resolved content exactly

Lighthouse CI Assertion

# lighthouserc.yml
ci:
  assert:
    assertions:
      first-contentful-paint: [warn, { maxNumericValue: 1800 }]
      largest-contentful-paint: [error, { maxNumericValue: 2500 }]
      total-blocking-time: [error, { maxNumericValue: 200 }]
      cumulative-layout-shift: [error, { maxNumericValue: 0.1 }]

Two operational details decide whether streaming survives contact with production. The first is the proxy layer: a CDN or reverse proxy that buffers the document response will collapse every flush into one delivery, and the page will behave exactly as if it were never streamed. Disable response buffering for HTML routes and verify with a raw curl --no-buffer against the production origin, watching for the shell to appear before the slow fragment. The second is the runtime: an edge runtime streams naturally, whereas a serverless function behind an API gateway may only support a buffered response body, silently converting every streamed route into a slow one at deploy time rather than at build time.

Failure Modes

1 β€” Excessive Suspense Nesting Fragments the Payload

Nesting more than three or four <Suspense> levels multiplies the number of Flight chunks, increases client-side reconciliation time, and makes it harder to reason about loading states.

Symptom: The Performance tab shows many small Evaluate Script spikes instead of a few larger ones. TTFB is fast but Time to Interactive is slow.

Fix: Flatten boundaries. Use loading.tsx for coarse route-level fallbacks and reserve nested boundaries only for genuinely data-dependent, non-critical UI blocks like comment threads or analytics charts.

// Before: three levels of nesting for no performance gain
<Suspense fallback={<OuterSkeleton />}>
  <OuterPanel>
    <Suspense fallback={<InnerSkeleton />}>
      <InnerWidget>
        <Suspense fallback={<DeepSkeleton />}>
          <DeepData />
        </Suspense>
      </InnerWidget>
    </Suspense>
  </OuterPanel>
</Suspense>

// After: two levels β€” outer covers the whole panel, inner only for the slow part
<Suspense fallback={<OuterSkeleton />}>
  <OuterPanel>
    <InnerWidget>
      <Suspense fallback={<DeepSkeleton />}>
        <DeepData />
      </Suspense>
    </InnerWidget>
  </OuterPanel>
</Suspense>

2 β€” Non-Serialisable Props Cause Hydration Mismatches

Passing Date, Map, Set, BigInt, or class instances as props across a use client boundary causes a hydration mismatch error because JSON serialisation silently drops these types.

Symptom: Error: Hydration failed because the server rendered HTML didn't match the client. in the browser console, often immediately after a boundary resolves.

Fix: Serialise on the server, reconstruct on the client.

// ❌ Breaks: Date is not JSON-serialisable
<DateDisplay createdAt={new Date(post.createdAt)} />

// βœ… Fixed: pass ISO string across the boundary, reconstruct in the client component
<DateDisplay createdAt={new Date(post.createdAt).toISOString()} />
// components/DateDisplay.tsx
'use client';
export default function DateDisplay({ createdAt }: { createdAt: string }) {
  // Reconstruct the Date safely on the client
  const date = new Date(createdAt);
  return <time dateTime={createdAt}>{date.toLocaleDateString()}</time>;
}

3 β€” Unbounded revalidate: 0 on High-Traffic Routes

Setting revalidate: 0 (equivalent to dynamic = 'force-dynamic') on every fetch call disables the Data Cache entirely. Under traffic spikes this causes every concurrent request to issue its own upstream fetch, collapsing cache hit rates and spiking origin latency.

Fix: Apply revalidate: 0 only to fetches that truly require per-request freshness (session tokens, personalised pricing). Use tag-based revalidation for everything else:

// For user-specific data that must be fresh on every request
const session = await fetch('/api/session', { cache: 'no-store' });

// For shared product data β€” cache for one hour, invalidate surgically
const product = await fetch(`/api/products/${id}`, {
  next: { tags: [`product-${id}`], revalidate: 3600 },
});

// In a Server Action after a product update:
// revalidateTag(`product-${updatedId}`);  ← only this product's cache entry clears

One Await Above the Shell Cancels Streaming Two waterfalls compared. In the streamed case the shell is written at forty milliseconds, content follows, and the slow fragment completes at nine hundred milliseconds without delaying anything before it. In the buffered case an await placed in the layout holds the whole response until the slow call returns, so the browser receives nothing at all until nine hundred milliseconds and paints everything at once. Same data, same server, one line of difference streamed await inside Suspense shell at 40 ms β€” page is readable content + markers reviews pending β†’ placeholder holds the space β†’ resolved at 900 ms buffered await in the layout nothing written β€” connection open, browser blank everything at 900 ms β€” FCP, LCP and hydration collide Diagnosis: compare time-to-first-byte with first paint. If they are identical, something above the shell is awaiting.

Frequently Asked Questions

Why does my Suspense fallback flash briefly even with fast data?

React always commits the fallback on the server before the async component resolves. Use a startTransition wrapper on navigation to suppress the fallback for fast transitions while keeping it for genuinely slow fetches.

Can I pass a Date object as a prop to a 'use client' component?

No. Props crossing the server/client boundary must be JSON-serializable. Convert Date to an ISO string on the server and reconstruct it with new Date(isoString) on the client.

How do I invalidate a streamed chunk without re-rendering the whole route?

Tag your fetch calls with next: { tags: ['my-tag'] } and call revalidateTag('my-tag') inside a Server Action or Route Handler. Only the component whose data carries that tag will be re-streamed.

← Back to Framework-Specific Islands & Streaming SSR