When to Choose React Server Components Over Islands

The islands architecture versus React Server Components comparison establishes that the two models optimise the same waste from different directions. This page turns that comparison into a repeatable decision. Rather than argue in the abstract, you will measure three concrete signals — the interactivity ratio, the data-fetching shape, and the team stack — and let the numbers point to RSC or to islands. The workflow assumes you can build a small prototype route in either model, because the final arbiter is always a measured budget, not a preference.

Prerequisites


RSC-vs-Islands Decision Flow A top-to-bottom decision flow. First node measures interactivity ratio; a low ratio branches toward islands. A high ratio flows to a data-fetching node; deeply co-located server data branches toward RSC. That flows to a team-stack node; a React App Router stack confirms RSC, otherwise islands. 1 · Interactivity ratio interactive surface / total surface < 30% → static-heavy 2 · Data-fetching shape server data co-located at many nodes? 3 · Team stack React + App Router target? React Server Components Islands Architecture Any "no" on nodes 2 or 3 with a static-heavy page favours islands

Decision Steps

Step 1 — Measure the interactivity ratio

Goal: Quantify how much of the page genuinely needs client JavaScript, so the choice rests on data rather than intuition.

The interactivity ratio is the fraction of rendered surface that requires event handlers or reactive state. Count DOM subtrees, weighted by area, that a user can actually interact with.

// Paste in the DevTools console on the target page. Rough but decisive:
// sums the pixel area of candidate-interactive nodes over the viewport area.
const interactiveSel = 'button, a[role="button"], input, select, textarea, ' +
  '[contenteditable], [role="tab"], [role="switch"], [onclick], [data-interactive]';
const vp = window.innerWidth * window.innerHeight;
let interactiveArea = 0;
for (const el of document.querySelectorAll(interactiveSel)) {
  const r = el.getBoundingClientRect();
  if (r.width && r.height) interactiveArea += r.width * r.height;
}
const ratio = interactiveArea / vp;
console.log(`interactivity ratio ≈ ${(ratio * 100).toFixed(1)}%`);

Expected output: A percentage such as interactivity ratio ≈ 22.4%. Below ~30 percent on a content-dominant page points toward islands; above ~40 percent, especially when interactive nodes are spread through a data-heavy tree, points toward RSC. Treat the 30–40 percent band as “measure the prototype”.

Step 2 — Characterise the data-fetching shape

Goal: Decide whether server data is co-located at many nodes, which is exactly where RSC removes the most glue code.

Sketch the tree and mark every node that needs to read server-only data (database, secret-guarded API, filesystem). If those reads sit near the top and flow down as props, islands handle it fine — fetch in the server template, pass props. If reads are scattered across leaves that also render UI, RSC lets each Server Component await its own data without a client fetch waterfall.

// RSC shines here: three independent leaf components each await their own data,
// all on the server, streamed under Suspense — no client fetch, no prop drilling.
export default async function Dashboard() {
  return (
    <main>
      <Suspense fallback={<Skeleton/>}><Revenue/></Suspense>   {/* awaits db */}
      <Suspense fallback={<Skeleton/>}><Signups/></Suspense>   {/* awaits db */}
      <Suspense fallback={<Skeleton/>}><Churn/></Suspense>     {/* awaits db */}
    </main>
  );
}
// Replicating this with islands means either fetching everything in one server
// template (losing per-widget streaming) or shipping client fetch logic per island.

Expected output: A labelled tree. Many independent server-data leaves → RSC advantage. A single top-level fetch feeding static markup with a few interactive widgets → islands advantage.

Step 3 — Audit the team stack

Goal: Confirm the operational fit, because the best model on paper is worthless if the team cannot ship or run it.

RSC requires React, a compatible bundler, and a server runtime that supports the App Router model. Islands frameworks such as Astro allow mixing React, Svelte, Solid, and Vue on one page. Answer plainly: Is the codebase React-centric? Is the deploy target an App Router-capable runtime? Does the team need multiple UI frameworks on one page?

Expected output: A short yes/no table. All-React with an App Router target and no multi-framework requirement confirms RSC. A polyglot component library, a non-React design system, or a static-host deploy target favours islands.


Verification

  1. Prototype one route in each model. Build the single most representative route as both an RSC page and an islands page. Anything less than a real route hides the runtime baseline.
  2. Compare JS-at-load. In the Network panel, sum transferred JavaScript bytes for a cold load. Confirm the islands prototype shows near-zero JS for static zones and the RSC prototype shows its React runtime baseline plus per-boundary chunks. If the RSC bundle is far larger than the interactivity ratio predicts, hunt for a stray high-level 'use client'.
  3. Compare TTI and TBT under throttling. Record both prototypes at 4× CPU throttle. The correct model is the one that meets your stated budget with margin, not the one that wins by a few milliseconds on an idle machine.
  4. Confirm the ratio held. Re-run the Step 1 script on the built prototype. If the measured ratio drifted far from your sketch, revisit the decision — the surface was more or less interactive than assumed.

Troubleshooting

The RSC prototype ships a large client bundle despite few interactive components

Root cause: A 'use client' directive sits on a high-level layout, provider, or shared wrapper, pulling its entire import subtree onto the client. RSC only keeps code off the client when the boundary is placed at the interactive leaf.

Fix: Move 'use client' down to the smallest component that needs state or events. Keep wrappers as Server Components and pass server-rendered content in as children rather than importing it beneath the boundary.

// Server Component wrapper; only the tiny <LikeButton/> below is 'use client'.
export default function Card({ children }: { children: React.ReactNode }) {
  return <article>{children}</article>; // children stay server-rendered
}
The islands prototype has a fast shell but a slow, janky interactive widget

Root cause: A single island carries too much work, or too many tiny islands each trigger a separate dynamic import, inflating Total Blocking Time. Islands reward bounded, genuinely interactive units, not fragmentation.

Fix: Consolidate adjacent interactive elements into one island and defer it with an idle or visible hydration directive. If the widget is inherently heavy and interleaved with server data, that is itself a signal the route may belong on RSC. Cross-check with how to calculate hydration overhead in React.

The interactivity ratio lands squarely in the 30–40% grey band

Root cause: The page mixes substantial static content with substantial interactivity, so neither model dominates on the ratio alone.

Fix: Break the tie with Steps 2 and 3. If server data is co-located across many leaves and the stack is React App Router, choose RSC. If interactivity is spatially isolated and the shell must stay runtime-free (or you need multiple frameworks), choose islands. When still tied, let the measured TTI of the two prototypes decide.


← Back to Islands Architecture vs React Server Components