Zero-Bundle React Server Components: Architecture, Streaming, and Hydration Pitfalls
React Server Components can remove server-only code from the client bundle, but only if boundaries, serialization, and streaming are designed correctly. Learn the architecture, production patterns, and hydration pitfalls that separate fast RSC apps from accidental client bundles.
Photo by Meet Patel on PexelsReact Server Components (RSC) are often pitched as a zero-bundle-size feature. That is directionally true, but dangerously incomplete: server components ship no component JavaScript to the browser, while every client component below a 'use client' boundary still ships, hydrates, and executes. Production RSC architecture is therefore a boundary-design problem, not a magic bundle-size switch. This guide covers the Flight architecture, streaming hydration behavior, and the pitfalls that turn a clean RSC design into a slow or broken client bundle.
The Zero-Bundle-Size Contract: What Actually Ships
An RSC executes only on the server. React serializes the rendered tree into the Flight payload, and the client runtime reconciles that payload without downloading the server component's implementation or its server-only dependencies. The zero-bundle benefit applies to the server component subtree and to libraries imported only by server modules.
The moment a module is marked with 'use client', it becomes a client boundary. That module and its client import graph are bundled, shipped, and hydrated. A common anti-pattern is putting 'use client' on a layout or page wrapper: that single directive can pull the entire interactive tree into the client bundle even when only one button needs state.
// app/products/[id]/page.tsx
import { cache, Suspense } from 'react';
import { notFound } from 'next/navigation';
import { AddToCart } from './add-to-cart';
import { Reviews } from './reviews';
type Product = { id: string; name: string; priceCents: number };
const getProduct = cache(async (id: string): Promise<Product | null> => {
const res = await fetch(`${process.env.API_URL}/products/${id}`, {
signal: AbortSignal.timeout(3000),
headers: { accept: 'application/json' },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Product fetch failed: ${res.status}`);
return (await res.json()) as Product;
});
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
if (!product) notFound();
return (
<main>
<h1>{product.name}</h1>
<p>${(product.priceCents / 100).toFixed(2)}</p>
<AddToCart productId={product.id} />
<Suspense fallback={<p>Loading reviews…</p>}>
<Reviews productId={product.id} />
</Suspense>
</main>
);
}The page component, data fetching, and cache() deduplication stay on the server. Only AddToCart and Reviews cross into client or streamed regions according to their boundaries. Reviews is still a server component, so it streams markup without adding client JavaScript. AddToCart is a client component, so its code and dependencies ship.
// app/products/[id]/add-to-cart.tsx
'use client';
import { useState } from 'react';
type AddToCartProps = { productId: string; disabled?: boolean };
type CartState = { status: 'idle' | 'pending' | 'error'; message: string | null };
export function AddToCart({ productId, disabled = false }: AddToCartProps) {
const [state, setState] = useState<CartState>({ status: 'idle', message: null });
async function handleAdd(): Promise<void> {
if (state.status === 'pending') return;
setState({ status: 'pending', message: null });
try {
const res = await fetch('/api/cart', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ productId }),
});
if (!res.ok) throw new Error(`Cart update failed: ${res.status}`);
setState({ status: 'idle', message: 'Added' });
} catch (error) {
setState({
status: 'error',
message: error instanceof Error ? error.message : 'Unknown cart error',
});
}
}
return (
<>
<button onClick={handleAdd} disabled={disabled || state.status === 'pending'}>
{state.status === 'pending' ? 'Adding…' : 'Add to cart'}
</button>
{state.message ? <p role='alert'>{state.message}</p> : null}
</>
);
}Boundary Design in Practice
Keep 'use client' at leaves: add-to-cart controls, search filters, modals, and chart interactions. Pass serializable data from server to client, and keep data fetching above the boundary. If a client component needs to fetch after interaction, call a route handler or Server Function; do not pass arbitrary functions as props unless they are explicitly created as Server Function references.
RSC Architecture: Flight, Boundaries, and Serializable Props
Flight is React's streaming serialization protocol for server component trees. It encodes server-rendered elements, client component references, props, and Suspense holes as a sequence of rows. The browser does not hydrate server component output; it hydrates client components and uses the Flight data to place server-rendered payloads into the right slots.
This architecture has trade-offs. You save client JavaScript for server-only code, but you add server render latency and Flight payload bytes. You can stream earlier with Suspense, but every boundary introduces scheduling and fallback behavior. You can fetch data close to the component, but you must dedupe repeated requests. Zero bundle size is not zero runtime cost.
Props Are a Serialization Boundary
Props crossing from server to client must be serializable. Primitives, plain objects, arrays, promises, and supported built-ins work; functions and class instances generally do not. Event handlers belong inside the client component. If the server needs to trigger a mutation, expose a Server Function or call an API route.
// Invalid: a server component cannot pass an event handler to a client component.
type PanelProps = { onOpen: () => void };
export function Panel({ onOpen }: PanelProps) {
return <button onClick={onOpen}>Open</button>;
}Treat the server-client prop boundary like an API schema. Large or deeply nested objects increase Flight payload size even when they do not increase JavaScript bundle size. Select only the fields the client needs, and avoid passing secrets, database rows, or server-only class instances into client props.
Server-Only Enforcement
A server component can import a client component, but a client component must not import server-only code. The server-only package turns accidental leaks into build-time failures. Use it in data access modules, secret-bearing clients, and server utilities.
import 'server-only';
export async function getInventory(sku: string): Promise<number> {
const res = await fetch(`${process.env.INVENTORY_URL}/stock/${sku}`, {
signal: AbortSignal.timeout(1500),
cache: 'no-store',
});
if (!res.ok) throw new Error(`Inventory lookup failed: ${res.status}`);
const body = (await res.json()) as { available: number };
return body.available;
}Also audit barrel files. Importing one component from an index file can pull unrelated client modules into the graph, and importing a server module from a client boundary will fail or leak depending on configuration. Measure with framework bundle analyzers and inspect the client reference manifest in production builds.
Streaming Hydration: Suspense Boundaries and Selective Hydration
Streaming SSR sends an initial HTML shell, then later chunks fill Suspense holes. RSC streaming is similar but carries Flight rows and client references. The browser can paint the shell before slower server components finish. Client components hydrate when their JavaScript is available; React can prioritize hydration around user interaction, which is what selective hydration buys you.
The practical rule: use Suspense around slow, non-critical data, not around every component. A boundary around the product price or primary call-to-action delays meaningful paint and can cause layout shift. A boundary around reviews, recommendations, or analytics panels lets the critical shell stream immediately. Fallbacks should reserve layout space and match final dimensions.
// app/products/[id]/reviews.tsx
type Review = { id: string; author: string; body: string };
export async function Reviews({ productId }: { productId: string }) {
const res = await fetch(`${process.env.API_URL}/products/${productId}/reviews`, {
signal: AbortSignal.timeout(4000),
headers: { accept: 'application/json' },
});
if (!res.ok) throw new Error(`Reviews fetch failed: ${res.status}`);
const reviews = (await res.json()) as Review[];
if (reviews.length === 0) return <p>No reviews yet.</p>;
return (
<ul>
{reviews.map((review) => (
<li key={review.id}>
<strong>{review.author}</strong>: {review.body}
</li>
))}
</ul>
);
}Because Reviews is a server component, this streamed region does not add client bundle bytes. A rejected fetch throws to the nearest error boundary. In production, wrap independently failing streamed regions with error boundaries so a reviews outage does not destroy the product shell.
Hydration Mismatch Pitfalls That Survive RSC
Even with RSC, client components still hydrate. Mismatches happen when the server HTML and first client render disagree. Common causes: reading window, localStorage, Date.now(), Math.random(), or locale-dependent values during render; invalid HTML nesting that the browser repairs; and conditional rendering based on typeof window at module scope.
'use client';
import { useEffect, useState } from 'react';
export function ThemeLabel() {
const [theme, setTheme] = useState<'light' | 'dark' | null>(null);
useEffect(() => {
const stored = window.localStorage.getItem('theme');
setTheme(stored === 'dark' ? 'dark' : 'light');
}, []);
return <span>{theme ?? '…'}</span>;
}Do not silence mismatches with suppressHydrationWarning unless the difference is unavoidable and narrowly scoped, such as a timestamp rendered with a known tolerance. Suppression hides correctness bugs in adjacent markup. Test with production builds and slow network throttling; development warnings do not catch every mismatch.
Production Checklist for RSC and Streaming Hydration
- Keep
'use client'boundaries at interactive leaves; audit layouts, providers, and barrel imports for accidental client graph expansion. - Treat props crossing the server-client boundary as a schema: no functions, class instances, or oversized objects unless explicitly supported.
- Dedupe per-request data with
cache(); use framework-specific persistent caching only with explicit revalidation and invalidation. - Place Suspense boundaries around slow, non-critical regions; reserve layout space in fallbacks to avoid cumulative layout shift.
- Add error boundaries around streamed regions so a rejected fetch does not tear down the shell.
- Install
server-onlyin server modules and scan client bundles for environment secrets. - Prefer stable, deterministic render output; move browser APIs and time-dependent values into effects or client-only components.
- Monitor Flight payload size and TTFB, not only JavaScript bundle size.
Conclusion
RSC can genuinely remove server component code from the client bundle, but the win is earned by boundary discipline, serializable contracts, and deliberate streaming. Treat 'use client' as a budget decision, Suspense as a layout and prioritization tool, and hydration as a correctness problem. Teams that do this get faster shells, smaller client graphs, and fewer production surprises.
Sources & Technical References
- React documentation: Server Components. https://react.dev/reference/rsc/server-components
- React documentation:
use client. https://react.dev/reference/rsc/use-client - React documentation: Server Functions. https://react.dev/reference/rsc/server-functions
- React documentation:
<Suspense>. https://react.dev/reference/react/Suspense - React documentation:
hydrateRoot. https://react.dev/reference/react-dom/client/hydrateRoot - React RFC: Server Components. https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md
- Next.js documentation: Server Components. https://nextjs.org/docs/app/building-your-application/rendering/server-components
- Next.js documentation: Loading UI and Streaming. https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming
- MDN:
AbortSignal.timeout(). https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static - npm:
server-only. https://www.npmjs.com/package/server-only
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

1B Spans/Day in Go: OTel SDK vs. eBPF vs. Hand-Rolled Tracing — Measured p99, CPU per Span, and Real Cost per 1M Spans
A billion spans a day sounds like a hyperscaler number — it is really just ~2,900 RPS. We benchmarked the OpenTelemetry Go SDK, eBPF auto-instrumentation, and a hand-rolled arena tracer on the same gRPC service to separate instrumentation CPU cost from the number that actually destroys observability budgets: storage and indexing per 1M spans.

Kubernetes Autoscaling at 10k Pods: Karpenter vs Cluster Autoscaler vs CAST AI, Measured
We ran identical HPA-driven load ramps from 2,000 to 10,000 pods across three EKS clusters to measure p99 scale-up latency, bin-packing waste, spot interruption recovery, and true cost per 1,000 pod-hours. Here is the methodology, the raw numbers, and the architectural reasons behind them.

Component Testing at 1M Runs/Month: Playwright CT vs Cypress CT vs Vitest Browser Mode — Measured p95, Flake Rate, and True CI Cost
We executed one million component test runs across Playwright CT, Cypress CT, and Vitest browser mode on identical hardware and measured p95 wall time, first-attempt flake rate, and throughput. The surprising result: CI compute is under 0.5% of your real bill — flake triage is the other 98%.