Islands Architecture vs React Server Components
Both Islands Architecture and React Server Components (RSC) attack the same waste: shipping JavaScript for parts of a page that will never respond to a click. They arrive at different answers. Islands treat the page as a static HTML document with a handful of independently activated interactive zones. RSC treats the page as one React tree, split by an execution boundary, that streams to the browser as a serialised description rather than as component code. For a performance engineer deciding where the next dashboard should live, the distinction is not academic — it determines how many bytes of executable JavaScript reach the main thread, when Time to Interactive lands, and how much orchestration the team signs up for. This guide draws the boundary precisely.
Concept Definition & Scope
Islands Architecture, covered in depth in the core islands architecture and hydration models guide, is a delivery pattern: the server emits a complete, crawlable HTML document, and a small client scheduler activates individual interactive components — islands — by resolving each one’s JavaScript module on demand. The defining property is that static regions ship no framework runtime at all. Each island is a self-contained hydration unit with its own bundle, and islands can even be built in different frameworks on the same page.
React Server Components are a feature of React itself, not a whole-site architecture. RSC splits a single React component tree along an execution boundary. Server Components run only on the server; they can await data directly, and their rendered output is serialised into the Flight payload — a streaming, row-based description of the UI. They never ship their own code to the browser. Components annotated with 'use client' are the only ones whose JavaScript is bundled and hydrated on the client. The two models therefore agree on the goal — keep non-interactive UI off the client bundle — but disagree on the unit of granularity and on whether a shared runtime is required.
The cleanest way to hold the difference in your head: islands are about selective activation of a static document; RSC is about selective serialisation of one React tree. Islands lean on partial hydration to bound the work per component. RSC leans on streaming SSR to progressively deliver the Flight stream and its Suspense boundaries. This is a component-level distinction, unlike the route-level composition discussed under islands architecture vs micro-frontends.
Technical Mechanics
The RSC / Flight boundary
In the App Router model, a file is a Server Component by default. Adding 'use client' at the top of a module marks it — and everything it imports that also opts in — as a client boundary. The server render walks the tree; when it reaches a client boundary, it does not render the component’s code into HTML. Instead it emits a client reference into the Flight stream: a module ID plus the serialised props to pass across the boundary.
// app/product/[id]/page.tsx
// Server Component (no directive). Runs ONLY on the server.
// It can await data directly — no client fetch, no loading spinner shipped.
import { getProduct } from '@/lib/db';
import AddToCart from './AddToCart'; // a client component (below)
import Reviews from './Reviews'; // another Server Component
export default async function ProductPage({ params }: { params: { id: string } }) {
// This await happens on the server. The query never reaches the browser,
// and neither does getProduct's code — only its serialised result does.
const product = await getProduct(params.id);
return (
<main>
{/* Static, server-rendered markup: serialised into Flight, zero client JS */}
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* Crossing into a client boundary. React emits a client reference here.
Only `product.id` and `product.price` are serialised across —
they must be plain, JSON-serialisable values. */}
<AddToCart productId={product.id} price={product.price} />
{/* Still a Server Component — awaits its own data, ships no JS */}
<Reviews productId={product.id} />
</main>
);
}
// app/product/[id]/AddToCart.tsx
'use client'; // ← the hydration boundary. This module and its client imports ship JS.
import { useState, useTransition } from 'react';
import { addToCart } from './actions'; // a Server Action (see below)
export default function AddToCart({ productId, price }: { productId: string; price: number }) {
const [qty, setQty] = useState(1);
const [isPending, startTransition] = useTransition();
// Event handlers require client JS — this is exactly the code RSC keeps
// on the client. Everything above the 'use client' line stayed on the server.
function submit() {
startTransition(async () => {
// Calls the Server Action over the network; no client-side fetch wiring.
await addToCart(productId, qty);
});
}
return (
<div>
<input type="number" value={qty} min={1}
onChange={(e) => setQty(Number(e.target.value))} />
<button onClick={submit} disabled={isPending}>
{isPending ? 'Adding…' : `Add — $${(price * qty).toFixed(2)}`}
</button>
</div>
);
}
// app/product/[id]/actions.ts
'use server'; // marks these exports as Server Actions — callable from the client,
// executed on the server. The function body never ships to the browser.
import { revalidatePath } from 'next/cache';
import { cartInsert } from '@/lib/db';
export async function addToCart(productId: string, qty: number) {
// Runs on the server. The client only holds a reference (an action ID),
// so database credentials and query logic stay server-side.
await cartInsert(productId, qty);
revalidatePath('/cart'); // invalidate the cached RSC render for the cart route
}
The Flight response for the page above is not HTML. It is a stream of numbered rows the client React runtime reads progressively — plain nodes inline, client components as M-prefixed module references, and Suspense fallbacks as placeholder rows resolved later. The browser downloads the referenced client chunks (AddToCart), hydrates those boundaries, and leaves the server-rendered markup untouched.
The islands boundary, by contrast
An islands framework produces the same page as static HTML plus discrete activation markers. There is no shared reconciler waiting to reconcile a serialised tree — each marker points at an isolated module.
---
// product.astro — runs at build/request time on the server only.
// The frontmatter is server code; nothing here ships to the client.
import AddToCart from '../components/AddToCart.jsx';
import { getProduct } from '../lib/db';
const product = await getProduct(Astro.params.id);
---
<main>
<h1>{product.name}</h1>
<p>{product.description}</p>
</main>
Here AddToCart could be React, Preact, Svelte, or Solid — Astro loads only that island’s runtime, and only for that island. The static shell carries no reconciler at all, which is the structural reason a static-heavy page reaches interactivity sooner under islands than under RSC.
The Flight payload and streaming
Flight is not HTML and not JSON — it is a line-delimited stream of rows, each with a numeric id and a payload. Server Component output is emitted as inline element rows; every 'use client' boundary is emitted as a module reference row (conventionally M-prefixed) carrying the chunk id and the export name; suspended subtrees are emitted first as a lazy reference ($L) and later resolved by a row that fills that slot. The browser’s React runtime reads this stream progressively: it can paint resolved rows while later rows are still in flight, which is why an RSC route with several <Suspense> boundaries reveals content in waves rather than all at once.
// Simplified Flight wire rows for the product page above.
// M rows point at client chunks; J rows are the rendered tree; $L is a pending slot.
M1:{"id":"./AddToCart.tsx","chunks":["chunk-ac.js"],"name":"default"}
J0:["$","main",null,{"children":[
["$","h1",null,{"children":"Wireless Mouse"}],
["$","@1",null,{"productId":"sku_42","price":29}], // @1 → client ref M1
"$L2" // Reviews, still streaming
]}]
J2:["$","section",null,{"children":"…resolved Reviews subtree…"}]
The practical consequence for delivery: the Server Component subtree adds bytes to the Flight response but zero bytes to the client bundle, whereas each distinct 'use client' chunk is a real JavaScript download. This maps directly onto streaming SSR — Suspense boundaries are the chunk boundaries of the Flight stream, and misplacing them either blocks the whole response on one slow query or fragments it into too many round-trips.
Bundle and TTI implications
The two models have different floors and different slopes. An islands page has a near-zero bundle floor — a fully static shell downloads no framework runtime — and its bundle grows one island at a time, each island paying its own runtime unless they share a framework. Its Time to Interactive is bounded by the heaviest island scheduled to hydrate at load; defer the rest with partial hydration and TTI barely moves as the page grows.
An RSC page has a non-zero floor: the React runtime plus the Flight decoder always load, so even a page of pure Server Components ships a baseline bundle and spends main-thread time parsing the stream. Its slope, however, is gentle — adding Server Components adds Flight bytes, not bundle bytes, so a large data-dense tree stays cheap on the client as long as the 'use client' surface stays small. The crossover is therefore predictable: below a low interactive ratio the islands floor of ~0 KB wins; as interactivity climbs and interleaves with server data, RSC’s gentle slope and unified tree overtake a proliferation of islands. Quantify this for your own routes with when to choose React Server Components over islands.
Comparison
| Dimension | Islands Architecture | React Server Components |
|---|---|---|
| Unit of granularity | Independent island (component + marker) | Boundary within one React tree ('use client') |
| Client runtime | None for static zones; per-island runtime, any framework | React reconciler always loaded to decode Flight |
| Server → client transport | Static HTML + per-island JSON props | Flight payload (streamed row format) + client chunks |
| Data fetching | Fetched in server template, serialised as props | await directly in Server Components; co-located |
| JS-at-load floor | ~0 KB for a fully static shell | React runtime + Flight decode, even with no client components |
| Cross-component state | Manual — event bus / shared store across islands | Native React context within client boundaries |
The single most consequential row is client runtime. Islands can deliver a genuinely 0 KB-JavaScript page; RSC cannot, because the browser needs React to interpret the Flight stream and to hydrate any client boundary. In exchange, RSC gives you one coherent tree with native prop passing, context, and Suspense, where islands force explicit cross-boundary prop passing and hand-rolled inter-island communication.
Step-by-Step Integration
- Inventory interactivity. Walk the page and label each region static or interactive. If interactive regions are few and spatially isolated, islands map cleanly. If interactivity is threaded through a data-dense tree that also needs server data at many nodes, RSC’s unified tree reduces glue code.
- Pick the boundary primitive. For islands, choose your framework’s directive (Astro
client:*, or adata-hydratemarker in a custom runtime). For RSC, place'use client'as low in the tree as possible — every parent that opts in drags its subtree onto the client. - Serialise props deliberately. Both models cross a server/client seam. In RSC, props to client components must be serialisable through Flight (no functions except Server Actions, no class instances). In islands, props are JSON-encoded next to the mount node. Keep the payload minimal in both.
- Wire data fetching to the model. With RSC,
awaitinside Server Components and let Suspense stream. With islands, fetch in the server template and pass results as props, or fetch inside the island after hydration for user-specific data. - Instrument before scaling. Add a
PerformanceObserverfor hydration marks (islands) or measure Flight decode plus client-boundary hydration (RSC) before rolling the pattern out across routes.
Measurement & Validation
Measure the two models on the axis that actually differs: executable JavaScript delivered and when the main thread goes quiet.
// Works for both models. Run before the runtime boots to catch every mark.
// Islands emit `island:hydrated:*` marks; for RSC, mark Flight decode start/end
// and the first client-boundary commit inside a root useEffect.
const obs = new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.entryType === 'mark' || e.entryType === 'measure') {
console.log(`[hydration] ${e.name} @ ${e.startTime.toFixed(1)}ms ` +
(e.duration ? `(${e.duration.toFixed(1)}ms)` : ''));
}
}
});
obs.observe({ entryTypes: ['mark', 'measure'], buffered: true });
In the DevTools Performance panel under a 4× CPU throttle, record a cold load of each prototype. For islands, confirm static zones produce no Evaluate Script events. For RSC, expect an unavoidable baseline: React runtime evaluation plus Flight parsing, visible as script evaluation even when every visible component is a Server Component. Compare Total Blocking Time and the interactive audit value. A static-shell islands page routinely reports single-digit-kilobyte JS and near-zero TBT; an RSC page with the same visible content reports the React baseline plus per-boundary hydration. Detailed per-framework methodology lives in the Next.js App Router streaming patterns guide and in how to calculate hydration overhead in React.
Failure Modes
1. The accidental 'use client' cascade. Placing 'use client' on a high-level layout or a shared UI wrapper silently pulls its entire import subtree onto the client, erasing the RSC benefit. The symptom is a client bundle far larger than the interactive surface suggests. Fix: push the directive down to the leaf that actually needs state, and pass server-rendered children in as props rather than importing them below the boundary.
// Anti-pattern: 'use client' at the top drags Chart, Table, everything client-side.
// Fix: keep the wrapper a Server Component and inject a small client leaf as a child.
export default function Panel({ children }: { children: React.ReactNode }) {
return <section>{children}{/* only <Toggle/> below is 'use client' */}</section>;
}
2. Treating RSC as zero-JS. Teams migrate expecting an islands-like 0 KB floor and are surprised by a persistent baseline. The React runtime and Flight decoder always load. If a truly static, runtime-free page is the requirement, islands are the correct tool — RSC optimises the component payload, not the runtime payload.
3. Non-serialisable props across the boundary. Passing a function, Date, or class instance to a client component throws a Flight serialisation error at render time; the islands equivalent silently drops or corrupts the value during JSON encoding. Fix: cross the boundary with plain data only, and reconstruct rich objects inside the client component — see cross-boundary prop passing.
4. Misplaced Suspense collapsing the stream. Wrapping the entire page in a single <Suspense> (or none at all) forces the Flight response to wait on the slowest Server Component, erasing the streaming benefit and pushing back both First Contentful Paint and hydration of the client boundaries below it. The islands analogue is scheduling every island client:load, which serialises their activation on the main thread. Fix: place Suspense boundaries around independently-slow data leaves so fast content streams first, and reserve eager hydration for genuinely above-the-fold interactivity.
// Anti-pattern: one boundary makes the whole route wait on the slowest await.
// Fix: co-locate a boundary with each slow leaf so fast rows stream immediately.
<Suspense fallback={<Skeleton/>}><SlowFeed/></Suspense>
<FastHeader/> {/* streams without waiting on SlowFeed */}
Related
- When to Choose React Server Components Over Islands — a decision workflow for the exact fork this comparison sets up.
- Islands Architecture vs Micro-Frontends — the sibling comparison at route and team granularity rather than component granularity.
- Understanding Partial Hydration — the mechanism islands use to bound per-component client work.
- Next.js App Router Streaming Patterns — how Suspense boundaries govern Flight delivery in production RSC apps.
- Core Islands Architecture & Hydration Models — the architectural foundation both models are measured against.