React Streaming and Suspense in Production: Selective Hydration, Out-of-Order Streaming, and Loading Patterns in Next.js App Router
A deep dive into React's streaming architecture for production apps. Covers how selective hydration works, out-of-order streaming with the App Router, practical loading.tsx and Suspense boundary patterns, waterfall avoidance, and how to measure streaming performance.
Most engineers using the Next.js App Router understand the basics: wrap a slow component in <Suspense>, show a fallback, let it stream in. What they often do not understand is what is actually happening in the browser during that process, why out-of-order streaming matters, and how to place Suspense boundaries without creating a page full of janky skeleton flashes.
This article covers the internals of React’s streaming architecture, how selective hydration works, practical patterns for loading.tsx and Suspense in production App Router apps, and how to measure streaming performance so you know if your boundaries are actually helping.
How React Streaming Works Under the Hood
When React renders a page that includes <Suspense> boundaries, it does not wait for all async work to complete before sending HTML. It sends an HTTP response with Transfer-Encoding: chunked and flushes the synchronous portions of the page immediately. Each <Suspense> boundary that has not resolved yet is replaced with a placeholder: a comment node like <!--$?--> with a template element holding the fallback.
When a suspended component resolves on the server, React appends a <script> tag to the ongoing HTTP stream containing the rendered HTML for that boundary plus instructions to swap it into position. The browser receives and applies each chunk as it arrives without waiting for the full document.
Time 0ms: <html>...<main>...<ProductDetails />(static HTML)
<!--$?--><template id="B:0"><LoadingRelated /></template>
Time 200ms: <div hidden id="S:0">...<RelatedProducts />(resolved HTML)</div>
<script>
$RC("B:0", "S:0") // React's runtime swap function
</script>
Time 350ms: <div hidden id="S:1">...<RecommendedItems />(resolved HTML)</div>
<script>$RC("B:1", "S:1")</script>
</body></html>
The $RC calls are React’s internal replaceContent function. They perform a DOM swap: move the resolved content from the hidden container to the Suspense boundary position, remove the fallback. No full re-render. No virtual DOM diffing. Just a targeted DOM mutation from server-generated HTML.
This is meaningfully different from client-side rendering where the browser has to receive and execute JavaScript before it can render anything. With streaming, the browser starts painting real content within milliseconds of the first byte, and the rest flows in as work completes on the server.
Selective Hydration
Streaming HTML solves the first paint problem. Hydration is a separate concern. React has to attach event listeners and React state to the server-rendered HTML before the page is interactive.
Without selective hydration (pre-React 18), hydration was all-or-nothing: React would walk the entire tree and hydrate everything synchronously. A slow component or a large tree would block interactivity for the whole page.
Selective hydration changes this in two ways.
First, hydration is interleaved with browser work via scheduler. React yields between hydration chunks so the main thread is not blocked for long periods. Instead of one large synchronous task, hydration happens as a series of smaller tasks that the browser can interrupt.
Second, React prioritizes hydration based on user interaction. If the user clicks on a component that has not yet been hydrated, React immediately hydrates that component first, processes the event, and then continues hydrating the rest of the tree in background priority. The click is not lost and the user does not notice the hydration state.
// In production this happens automatically. The key insight is that you can
// interact with a streamed-in component before the rest of the page hydrates.
// React uses a replay queue for events that arrive before hydration.
// Example: a "Add to cart" button inside a streamed Suspense boundary can
// receive a click event before the surrounding layout finishes hydrating.
// React replays that event after hydrating the button's subtree first.
This matters for your Suspense boundary placement strategy. Components that users are likely to interact with early (search boxes, primary CTAs, navigation) should be either outside Suspense boundaries (so they hydrate with the initial shell) or in their own boundary so they hydrate independently.
Out-of-Order Streaming in the App Router
The App Router adds a layer on top of React’s streaming primitives. Routes are rendered as a tree of layout segments, and each segment can independently suspend. This produces what Next.js documentation calls out-of-order streaming: segments resolve and stream in any order based on when their data fetches complete.
Consider a product page with this segment structure:
app/
layout.tsx (navigation, shell)
products/
layout.tsx (category sidebar)
[id]/
page.tsx (product details, reviews section)
Each level is a potential streaming point. The root layout renders immediately. The products layout may suspend while fetching the category tree. The product page suspends while fetching the specific product. Each resolves independently.
// app/products/layout.tsx
import { Suspense } from "react";
import { CategorySidebar } from "@/components/category-sidebar";
import { CategorySidebarSkeleton } from "@/components/skeletons";
export default function ProductsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="products-layout">
<Suspense fallback={<CategorySidebarSkeleton />}>
<CategorySidebar />
</Suspense>
<main>{children}</main>
</div>
);
}
// app/products/[id]/page.tsx
import { Suspense } from "react";
import { ProductDetails } from "@/components/product-details";
import { ReviewsSection } from "@/components/reviews-section";
import { ProductDetailsSkeleton, ReviewsSkeleton } from "@/components/skeletons";
interface Props {
params: Promise<{ id: string }>;
}
export default async function ProductPage({ params }: Props) {
const { id } = await params;
return (
<>
<Suspense fallback={<ProductDetailsSkeleton />}>
<ProductDetails id={id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewsSection productId={id} />
</Suspense>
</>
);
}
ProductDetails and ReviewsSection suspend independently. If ProductDetails resolves in 80ms and ReviewsSection takes 400ms, the product information streams in at 80ms and the reviews slot shows a skeleton until 400ms. The page does not block on the slower query.
This is the direct alternative to the old pages-directory pattern of getServerSideProps where all data fetches on a page completed before any HTML was sent.
The loading.tsx File
loading.tsx is syntactic sugar over <Suspense>. When you create a loading.tsx file in a route segment, Next.js automatically wraps the page.tsx (and any layouts below it) in a Suspense boundary with the loading.tsx content as the fallback.
// app/products/[id]/loading.tsx
export default function Loading() {
return (
<div className="product-page-skeleton">
<div className="skeleton-hero" />
<div className="skeleton-details">
<div className="skeleton-line w-3/4" />
<div className="skeleton-line w-1/2" />
<div className="skeleton-price" />
</div>
</div>
);
}
The important detail is scope. loading.tsx wraps the entire segment, which means it shows when any async work in that segment (or its children) is not yet resolved. If you have four async components in a page and only one of them is slow, loading.tsx will show its skeleton until all four resolve. That is often too coarse.
Use loading.tsx for the coarse page-level loading state that appears on initial navigation to a route. Use explicit <Suspense> boundaries with targeted fallbacks for independent sections within a page.
// app/dashboard/loading.tsx
// This shows when navigating TO the dashboard for the first time.
// It should match the rough layout of the page to avoid layout shift.
export default function DashboardLoading() {
return (
<div className="dashboard-grid">
<div className="skeleton-card" />
<div className="skeleton-chart" />
<div className="skeleton-table" />
</div>
);
}
// app/dashboard/page.tsx
// Within the page, use granular Suspense for sections with different fetch times.
import { Suspense } from "react";
import { MetricsCard } from "@/components/metrics-card";
import { RevenueChart } from "@/components/revenue-chart";
import { RecentTransactions } from "@/components/recent-transactions";
export default function DashboardPage() {
return (
<div className="dashboard-grid">
<Suspense fallback={<MetricsCardSkeleton />}>
<MetricsCard />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentTransactions />
</Suspense>
</div>
);
}
The layout shift problem is real here. If your skeletons do not match the dimensions of the real content, you will see content jumping as each section resolves. Skeleton components need to be layout-accurate, not just visually plausible. Use fixed heights or aspect ratios that match the real content.
Waterfall Avoidance
The primary source of waterfalls in App Router apps is sequential await calls inside async Server Components when those calls are independent.
// Waterfall: these run sequentially even though they are independent.
async function ProductPage({ id }: { id: string }) {
const product = await getProduct(id); // 80ms
const reviews = await getReviews(id); // 120ms
const related = await getRelatedItems(id); // 90ms
// Total: ~290ms before any HTML
}
// Parallel: fire all three at once with Promise.all.
async function ProductPage({ id }: { id: string }) {
const [product, reviews, related] = await Promise.all([
getProduct(id),
getReviews(id),
getRelatedItems(id),
]);
// Total: ~120ms (the slowest of the three)
}
Promise.all is the straightforward fix for independent queries in the same component. But if you also want independent streaming (show product details before reviews are ready), combine Promise.all within each component with Suspense across components:
// ProductDetails.tsx fetches only what it needs, fast.
async function ProductDetails({ id }: { id: string }) {
const product = await getProduct(id); // 80ms
return <div>{product.name}...</div>;
}
// ReviewsSection.tsx fetches separately, can be slower without blocking product.
async function ReviewsSection({ productId }: { productId: string }) {
const reviews = await getReviews(productId); // 120ms
return <div>{reviews.map(...)}</div>;
}
// page.tsx: both components fetch in parallel (server renders both simultaneously)
// and each streams in when ready.
export default function Page({ id }: { id: string }) {
return (
<>
<Suspense fallback={<ProductDetailsSkeleton />}>
<ProductDetails id={id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewsSection productId={id} />
</Suspense>
</>
);
}
React renders both ProductDetails and ReviewsSection in parallel on the server. They do not wait for each other. Each streams independently when its data is ready.
The other common waterfall is layout-level data fetching that blocks child segments. If your root layout awaits a session fetch and that fetch is slow, every page under that layout waits for the session before rendering. Extract what you need early and be specific:
// app/layout.tsx
// Fine: fast cookie read. No network call.
import { cookies } from "next/headers";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value ?? "light";
return (
<html data-theme={theme}>
<body>{children}</body>
</html>
);
}
// app/(authenticated)/layout.tsx
// If you need auth here, use a fast token verification, not a full user DB fetch.
import { verifySession } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function AuthenticatedLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await verifySession(); // JWT verify: <5ms
if (!session) redirect("/login");
// Do NOT fetch the full user profile here.
// Pass session.userId as a prop/context and let each page fetch its own data.
return <>{children}</>;
}
Tradeoffs
| Dimension | Coarse Suspense (loading.tsx only) | Granular Suspense (per-section) | No Suspense (all await in page) |
|---|---|---|---|
| Time to first byte | Fast | Fast | Depends on slowest fetch |
| Perceived performance | Full page skeleton, then full paint | Progressive reveal per section | Full blank then full paint |
| Layout shift risk | Low (one skeleton, one swap) | High (multiple swaps, needs accurate skeletons) | None (content arrives complete) |
| Complexity | Low | Medium | Low |
| Interactivity timing | After page hydrates | Per section as it hydrates | After full hydration |
| Best for | Navigation loading states | Dashboards, feeds, mixed-speed fetches | Fast pages where all data is available quickly |
The layout shift risk with granular Suspense deserves emphasis. Every Suspense boundary that resolves is a potential layout shift event. Browsers calculate Cumulative Layout Shift (CLS) across the full document lifetime. If your skeletons do not hold space accurately, you will see CLS degradation even though the page feels fast. Use explicit min-height or aspect-ratio on skeleton containers.
Production Considerations
Suspense boundary placement and error boundaries. Every <Suspense> boundary should have a corresponding error boundary wrapping it, or you should use the error.tsx convention at the segment level. An async Server Component that throws an unhandled error will crash the stream. Catching errors at the boundary level lets you show a targeted error state instead of killing the whole page.
// Using React's ErrorBoundary with Suspense together
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
export default function FeedPage() {
return (
<ErrorBoundary fallback={<div>Failed to load feed.</div>}>
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
</Suspense>
</ErrorBoundary>
);
}
Or with the App Router file convention:
app/
feed/
page.tsx
loading.tsx (Suspense fallback)
error.tsx (ErrorBoundary fallback)
Streaming and caching. Next.js’s full-route cache does not apply to dynamically rendered routes (routes that read cookies, headers, or use noStore()). When a route is dynamic, every request triggers a fresh render and fresh streaming. This is expected but it means your data fetching functions need to be fast. Measure per-query latency in production, not just aggregate page load time.
use cache and streaming. React 19 and Next.js 15 introduce the use cache directive for caching async Server Component work at the function level. Cached functions still participate in streaming: if the cached value is stale, the component suspends while it revalidates, and the fresh value streams in. The interaction between cache staleness and Suspense fallback timing is worth testing explicitly.
// With use cache, this result is memoized across requests until invalidated.
// The Suspense boundary still shows a fallback on cache miss.
"use cache";
export async function getProductCached(id: string) {
const product = await db.product.findUnique({ where: { id } });
return product;
}
Measuring streaming performance. Browser DevTools Network tab shows chunked transfer but it does not tell you at what point the meaningful content arrived. Use the Performance tab to look at the timeline of DOM mutations: you want to see real content nodes appearing in stages, not one large batch at the end. For automated measurement, the LargestContentfulPaint and FirstContentfulPaint entries from PerformanceObserver are your primary signals.
// In a client-side monitoring script, measure streaming effectiveness.
if (typeof window !== "undefined") {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === "largest-contentful-paint") {
console.log("LCP:", entry.startTime, "element:", entry.element);
}
}
});
observer.observe({ type: "largest-contentful-paint", buffered: true });
}
Good streaming should move your LCP element to content that arrives in the first flush or the first resolved Suspense boundary. If your LCP element is inside a Suspense boundary that takes 500ms to resolve, the user stares at a skeleton for 500ms before the browser registers a paint event for the actual content. In that case, either pull the LCP element outside the boundary or pre-fetch the data so the boundary resolves faster.
Avoid Suspense inside Client Components. <Suspense> works with async Server Components on the server. Inside Client Components, it works with lazy-loaded components and with data libraries that implement the Suspense protocol (like React Query with suspense: true). Using <Suspense> around a Client Component that does a useEffect fetch does nothing useful because useEffect does not trigger Suspense.
Closing
React’s streaming architecture gives you a way to decouple “server is thinking” from “browser has nothing to show.” The mechanisms that make this work are chunked HTTP transfer for progressive HTML delivery, React’s RSC payload swapping for out-of-order resolution, and selective hydration for prioritizing interactivity where the user is actually clicking.
The practical discipline is: use loading.tsx for route-level navigation states, use explicit <Suspense> boundaries for sections with meaningfully different data fetch latencies, keep skeletons layout-accurate to avoid CLS, and always pair Suspense boundaries with error handling. Most of the performance wins come from getting Suspense boundary placement right, not from any single optimization. Measure with real browser tooling against production data latencies, not localhost.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.