In traditional buffered Server-Side Rendering (SSR) architectures, a critical performance bottleneck existed: the server had to await the completion of all asynchronous operations (database queries, microservice roundtrips, and third-party APIs) before transmitting even a single byte of generated HTML to the client.
If 95% of a page's content is static and ready to render in 20 ms, but one peripheral widget (such as personalized recommendations or a live social feed) takes 2 seconds to query, the user stares at a blank screen for a full 2 seconds. This drastically degrades core Web Vitals metrics:
Streaming SSR and React Suspense dismantle this monolithic bottleneck. The server immediately streams the static page shell, navigation chrome, and loading skeletons, streaming slower components into the browser over the open connection as their data resolves.
Under streaming architectures, an HTML document is no longer delivered as a monolithic payload with a static Content-Length. Instead, the connection transitions to Chunked Transfer Encoding (or multiplexed data framing in HTTP/2 and HTTP/3).
The server partitions the component hierarchy into two architectural tiers:
layout.tsx), header navigation, footers, and fallback skeletons. Dispatched to the client in under 30–50 ms.HTTP Connection (Transfer-Encoding: chunked / HTTP/2)
────────────────────────────────────────────────────────────────────────────►
[CHUNK 1: Page Shell + Skeletons]
<!DOCTYPE html><html>
<body>
<nav>Site Navigation</nav>
<div id="slot-reviews">
<!--$?--><template id="B:0"></template><div class="skeleton">Loading reviews...</div><!--/$-->
</div>
</body>
</html>
────────────────────────────────────────────────────────────────────────────► Browser paints FCP!
... (Server concurrently evaluates reviewsDb.findMany()) ...
[CHUNK 2: Resolved Markup + DOM Swap Script]
<div hidden id="S:0">
<div class="reviews-list">Review 1, Review 2, Review 3</div>
</div>
<script>
$RC("B:0", "S:0"); // React internal runtime: atomically swaps B:0 with S:0
</script>
────────────────────────────────────────────────────────────────────────────► Skeleton instantly
replaced by content!The server initiates route evaluation and immediately transmits Transfer-Encoding: chunked headers, <html>, <head>, and the static shell.
The browser parses the initial chunk, rendering navigation and layout skeletons immediately. TTFB drops to raw static latency.
The server runs independent asynchronous database queries for components wrapped in Suspense boundaries in parallel.
As each component resolves, the server flushes its markup alongside an inline $RC() script that natively replaces the skeleton in the DOM.
React hydrates interactive client components individually without waiting for remaining slow segments to finish loading.
How does the browser place a streamed HTML chunk into its correct location in the DOM tree when the chunk arrives at the tail end of an open HTTP connection?
The loading.tsx file convention automatically wraps the adjacent page.tsx (and nested child trees) within a root <Suspense fallback={<Loading />}> boundary:
export default function AnalyticsLoading() {
return (
<div className="p-8 space-y-6 animate-pulse">
<div className="h-8 w-64 bg-bg-elevated rounded-lg" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="h-32 bg-bg-elevated rounded-xl border border-border" />
<div className="h-32 bg-bg-elevated rounded-xl border border-border" />
<div className="h-32 bg-bg-elevated rounded-xl border border-border" />
</div>
<div className="h-80 bg-bg-elevated rounded-xl border border-border" />
</div>
);
}Relying solely on loading.tsx causes the entire page to block on its slowest constituent component. For maximum concurrency, wrap independent widgets in granular <Suspense> tags:
import { Suspense } from 'react';
import { MetricsOverview } from '@/components/MetricsOverview';
import { RealtimeTrafficChart } from '@/components/RealtimeTrafficChart';
import { TransactionFeed } from '@/components/TransactionFeed';
import { CardSkeleton, ChartSkeleton, FeedSkeleton } from '@/components/skeletons';
export default function AnalyticsPage() {
return (
<main className="p-8 space-y-8 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold text-text-primary">Analytics Dashboard</h1>
{/* 1. Fast Metrics Block: resolves rapidly (~100 ms) */}
<Suspense fallback={<CardSkeleton />}>
<MetricsOverview />
</Suspense>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 2. Heavy Chart: complex aggregation query (~800 ms) */}
<div className="lg:col-span-2">
<Suspense fallback={<ChartSkeleton />}>
<RealtimeTrafficChart />
</Suspense>
</div>
{/* 3. Realtime Transaction Feed: external API fetch (~400 ms) */}
<div className="lg:col-span-1">
<Suspense fallback={<FeedSkeleton />}>
<TransactionFeed />
</Suspense>
</div>
</div>
</main>
);
}use())#In React 19, you can stream unresolved Promise objects across a Suspense boundary. The Server Component initiates queries without await, and the Client Component unwraps data directly using the use() hook:
import { Suspense } from 'react';
import { CustomerReviews } from './CustomerReviews';
import { getReviewsPromise } from '@/lib/db/reviews';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function ProductPage({ params }: PageProps) {
const { id } = await params;
// Initiate query WITHOUT await: promise begins resolving in the background
const reviewsPromise = getReviewsPromise(id);
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<h1 className="text-2xl font-bold">Product Item #{id}</h1>
<Suspense fallback={<div className="h-40 bg-bg-elevated rounded-lg animate-pulse" />}>
{/* Pass unresolved Promise down to the Client Component */}
<CustomerReviews promise={reviewsPromise} />
</Suspense>
</div>
);
}'use client';
import { use } from 'react';
interface Review {
id: string;
rating: number;
text: string;
}
export function CustomerReviews({ promise }: { promise: Promise<Review[]> }) {
// The use() hook unrolls the promise and coordinates with parent Suspense
const reviews = use(promise);
return (
<div className="border border-border rounded-xl p-6 bg-bg-surface space-y-4">
<h2 className="text-lg font-bold">Customer Reviews ({reviews.length})</h2>
<div className="divide-y divide-border">
{reviews.map((r) => (
<div key={r.id} className="py-3">
<span className="text-accent-cyan font-semibold">Rating: {r.rating}/5</span>
<p className="text-text-muted mt-1">{r.text}</p>
</div>
))}
</div>
</div>
);
}connection()#In Next.js 15+, to prevent dynamic code from executing during build-time static pre-rendering, use the connection() API:
import { connection } from 'next/server';
import { redis } from '@/lib/redis';
export async function LiveUserCounter() {
// Await incoming client connection explicitly.
// Signals Next.js that this component must execute strictly during request streaming!
await connection();
const activeUsers = await redis.get('online_users_count');
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-success/10 border border-success/20 text-success text-xs font-mono">
<span className="w-2 h-2 rounded-full bg-success animate-ping" />
Online Now: {activeUsers ?? 0}
</div>
);
}What happens if a user clicks an interactive 'Add to Cart' button before its JavaScript chunk has finished downloading over a slow 3G mobile network?
Executing an un-wrapped await getGlobalSettings() directly in the root body of app/layout.tsx or app/page.tsx prevents Next.js from dispatching even the initial <html> opening tag and <head> metadata!
The entire page falls back to monolithic buffered SSR, and TTFB increases by the latency of that query.
Mitigation: Keep root layouts lean and synchronous. Push asynchronous queries down into independent leaf components wrapped in <Suspense fallback={<Skeleton />}>.
Streaming can cause severe spikes in CLS (Cumulative Layout Shift) if a skeleton fallback is 50px high while the resolved component occupies 400px. When the content replaces the skeleton, subsequent elements jump jarringly down the page.
Mitigation: Skeletons must reserve exact physical dimensions (using fixed min-h-[...], aspect-ratio, or CSS grid structures) mirroring the real component.
Splitting a page into multiple <Suspense> boundaries does not fix latency if an individual component executes sequential await calls (await getProfile(); await getHistory();).
Mitigation: For independent queries, execute them in parallel via Promise.all() or split them into separate components with their own Suspense boundaries.
// ❌ Hidden waterfall inside Suspense (1000ms total)
async function UserActivity({ userId }: { userId: string }) {
const profile = await getProfile(userId); // 500ms
const history = await getHistory(profile.accountId); // additional 500ms
return <div>...</div>;
}
// ✅ PARALLEL EXECUTION (500ms total):
async function UserActivityParallel({ userId, accountId }: { userId: string; accountId: string }) {
const [profile, history] = await Promise.all([getProfile(userId), getHistory(accountId)]);
return <div>...</div>;
}While major search engines (like Googlebot) support streaming and wait up to several seconds for promises to resolve, social media scrapers (Twitter, Facebook, Telegram) parse only the initial HTML chunk.
Wrapping article titles (<h1>), meta tags, or critical product descriptions inside <Suspense> can lead social scrapers to index empty loading skeletons. Always preserve critical above-the-fold content in the synchronous shell!