Framework-Specific Islands & Streaming SSR

Monolithic hydration penalises every visitor: the browser must download, parse, and execute a full JavaScript bundle before any component becomes interactive, regardless of how much of the page actually requires client-side logic. For SaaS dashboards and content-heavy applications, that cost compounds — long tasks block the main thread, Time to Interactive climbs, and Interaction to Next Paint degrades on mid-range devices. This guide walks through how each major framework solves that problem through its own streaming and selective-hydration primitives, what the measurable tradeoffs look like, and where implementations break in production.


Islands + Streaming SSR Execution Model Diagram showing the server zone on the left emitting HTML chunks through Transfer-Encoding chunked boundaries into the browser on the right, where a hydration scheduler selectively activates only island-marked components while the static shell remains inert. SERVER ZONE Data Fetching & Template Render DB / API / RSC / load() Static Shell Emission Transfer-Encoding: chunked · TTFB ↓ Island Markers / Suspense Boundaries data-island · <Suspense> · {#await} Deferred Chunk Flush Promise resolution → stream continuation HTTP chunks incremental BROWSER ZONE HTML Parser (progressive DOM build) Static shell renders before JS arrives Hydration Scheduler IntersectionObserver · requestIdleCallback · event Island A Interactive · JS attached Static region No JS · inert Deferred Island Hydration Chunk resolves → runtime activates island

Architectural Foundations

Islands architecture imposes a strict separation between the static shell — server-rendered HTML that the browser can parse and display with zero JavaScript — and dynamic islands, which are explicitly marked interactive zones that receive a JavaScript payload only when activation conditions are met. As explained in depth under Core Islands Architecture & Hydration Models, the server emits hydration markers (custom attributes, HTML comments, or framework-specific sentinel nodes) that act as execution anchors; the client runtime reads those markers and attaches event listeners only to designated DOM nodes.

Streaming SSR complements this by using Transfer-Encoding: chunked to flush HTML progressively as server-side promises resolve, rather than blocking the response until the full page is serialised. The two techniques are orthogonal: streaming controls when HTML arrives; islands control what JavaScript executes. Using them together decouples TTFB from Time to Interactive in a way neither technique achieves alone.

Key terms used throughout this page:

  • Island — a self-contained interactive component embedded in a static shell, hydrated independently of its neighbours.
  • Static shell — the inert HTML frame that is painted before any JavaScript runs.
  • Hydration marker — a declarative signal in the HTML (data-island, q:host, React’s <!--$--> comment) that tells the client runtime where to attach.
  • Execution boundary — the explicit line between server-only code (data fetching, template rendering) and client code (event handlers, reactive state).
  • Streaming chunk — a segment of the HTTP response flushed independently; later chunks may include island payloads deferred behind Promise resolution.

Execution Pipeline

The ordered lifecycle from request to interactive island:

  1. Request arrives at the server (Node.js, edge runtime, Deno, Bun).
  2. Data layer executes — database queries, API calls, RSC flight data.
  3. Static shell streams — the HTML <head> and above-the-fold markup flush via Transfer-Encoding: chunked; TTFB is recorded here.
  4. Chunk boundaries mark deferred regions<Suspense> fallbacks (React), {#await} blocks (SvelteKit), or q:host serialisation (Qwik) act as placeholders.
  5. Async data resolves — the server flushes each resolved chunk, replacing placeholder content inline.
  6. HTML parser builds DOM progressively — the browser renders content as it arrives; no JS execution required at this stage.
  7. Hydration scheduler activates islands — based on viewport intersection, idle time, or explicit user events.
  8. Island runtime attaches — the minimal JS bundle for each island loads, state initialises, event listeners register.
// SERVER — Next.js App Router: static shell streams before data resolves
// Each <Suspense> boundary is an independent streaming chunk

import { Suspense } from 'react';

export default async function DashboardPage() {
  return (
    <main>
      {/* Step 3: Static shell flushes immediately — zero data dependency */}
      <header className="site-header">Analytics Dashboard</header>

      {/* Step 4: Placeholder streams; resolved chunk replaces it when promise settles */}
      <Suspense fallback={<div className="skeleton-chart" aria-busy="true" />}>
        <AnalyticsChart /> {/* resolves independently — does not block UserTable */}
      </Suspense>

      <Suspense fallback={<div className="skeleton-table" aria-busy="true" />}>
        <UserTable />      {/* separate chunk; streams as soon as its query resolves */}
      </Suspense>
    </main>
  );
}
// CLIENT BOUNDARY — interactive island hydrates after its chunk arrives
'use client'; // React 18: marks this module as a client-only island
import { useState } from 'react';

export function AnalyticsChart() {
  // Hydration runs once this component's chunk has been received and mounted
  const [range, setRange] = useState('7d');
  return <canvas aria-label={`Analytics — ${range}`} onClick={() => setRange('30d')} />;
}

Hydration Strategy Taxonomy

Choosing the wrong hydration trigger is the single most common source of unnecessary main-thread work. The table below maps each strategy to its activation condition, effect on TTI, main-thread cost, and the scenarios where it fits.

Strategy Trigger TTI Impact Main-Thread Cost When to Use
Eager DOMContentLoaded Immediate interactivity High — runs on load CTAs, modals, any component above the fold that must respond instantly
Lazy (on interaction) click, focus, pointerenter First interaction adds ~50–150 ms latency Very low baseline Dropdowns, tooltips, infrequently used widgets
Progressive (idle/visible) IntersectionObserver + requestIdleCallback Near-zero at load; hydrates off-screen components proactively Low — deferred to idle Below-fold charts, carousels, comment sections
Resumable User event triggers on-demand code fetch TTI ≈ 0 ms Minimal — per-event chunk fetch Qwik applications; high-interactivity pages that still need instant perceived load

Understanding partial hydration explains how progressive strategies reduce blocking time by splitting hydration work into discrete micro-tasks rather than a single synchronous tree walk.

Framework Landscape Overview

React / Next.js App Router

React’s implementation centres on React Server Components (RSC) and the Flight wire protocol. RSCs serialise component trees and data into a compact binary format; <Suspense> boundaries segment that stream. The App Router treats each Suspense subtree as an independent hydration unit — the browser patches the DOM incrementally as chunks arrive rather than waiting for a complete payload.

Full implementation details, edge runtime configuration, and hydration mismatch guards are in Next.js App Router Streaming Patterns.

Astro

Astro is static-first by default: every component renders to inert HTML unless explicitly marked with a client directive (client:load, client:visible, client:idle, client:media). Islands are framework-agnostic — a React chart and a Svelte counter can coexist on the same Astro page, each hydrated in isolation without interfering with the other’s runtime.

Directive configuration, multi-framework island composition, and the client:only escape hatch are covered in Astro Islands and Client Directives.

SvelteKit

SvelteKit diverges from runtime-heavy hydration through compile-time transformation: the Svelte compiler eliminates the virtual DOM and emits targeted imperative DOM updates. Streaming is achieved by returning an unawaited Promise from a load function; SvelteKit flushes the page shell immediately and streams the resolved value when the promise settles. Reactive stores are scoped per-component, preventing state from leaking across island boundaries.

Scoped hydration, store isolation, and {#await} streaming patterns are explored in SvelteKit Component Islands.

// +page.server.ts — returning an unawaited Promise enables streaming
export const load = ({ fetch }) => {
  // SvelteKit detects the Promise and streams the shell before resolution
  const metricsData = fetch('/api/metrics').then(r => r.json());
  return { metricsData }; // streamed — does NOT await here
};

Qwik

Qwik abandons hydration entirely in favour of resumability. During SSR, Qwik serialises all application state — component props, signal values, event handler references — directly into HTML attributes (q:state, q:host). No JavaScript executes on the initial load. When a user interacts with an element, Qwik fetches only the specific chunk required for that event handler, deserialises the relevant state from the attribute, and executes. This achieves TTI ≈ 0 ms because there is no hydration pass.

Architecture benchmarks, state serialisation strategies, and fine-grained lazy loading are in Qwik Resumable Architecture.

Resumability execution sequence:

  1. SSR serialises component state → q:state attributes written into HTML.
  2. HTML streams to client; DOM builds; page is visually complete.
  3. Zero JavaScript executes on load.
  4. User triggers an event → Qwik fetches the matching onClick$ chunk (~1–3 KB).
  5. State deserialises from attribute → handler executes → DOM updates.

Fresh (Deno)

Fresh uses Preact islands with zero runtime by default. Components are server-rendered; interactive regions are marked with an islands/ file convention and hydrated with a minimal Preact client. Because Fresh runs on Deno Deploy edge nodes globally, streaming SSR latency is dominated by origin proximity rather than JavaScript execution cost.

Marko

Marko implements fine-grained streaming at the template level via its <await> tag, which acts as a built-in streaming boundary without any framework-level Suspense analogue. Marko’s compiler tracks reactivity at the attribute level — only the exact DOM node that depends on a changed value is updated, with no virtual DOM diffing overhead.

Performance Measurement Baselines

Quantifying the benefit of islands and streaming SSR requires measuring before and after at the framework level. The key metrics and how to capture them:

Metric What it measures Target (good) How to capture
TTFB Time from request to first byte of response < 200 ms performance.timing.responseStart - performance.timing.requestStart
LCP Largest Contentful Paint — when the biggest visible element paints < 2.5 s PerformanceObserver with largest-contentful-paint entry type
TTI Time to Interactive — when the main thread is quiet for 5 s < 3.8 s Lighthouse CI timeToInteractive audit
TBT Total Blocking Time — sum of main-thread blocking > 50 ms < 200 ms Lighthouse CI totalBlockingTime; correlates with INP
INP Interaction to Next Paint — 98th-percentile input latency < 200 ms PerformanceObserver with event entry type; or field data via CrUX
Hydration delta Time between chunk arrival and island activation Framework-specific target performance.mark('island:start') / performance.mark('island:end')

Instrumentation Pattern

// Place this in each island's mount lifecycle (useEffect, onMount, $, etc.)
// to measure per-island hydration cost independently of page load

if (typeof performance !== 'undefined') {
  // Mark when the island's JS begins executing
  performance.mark('island:hydrate:start', { detail: { id: 'analytics-chart' } });

  // Call after state initialisation and first DOM update
  requestAnimationFrame(() => {
    performance.mark('island:hydrate:end', { detail: { id: 'analytics-chart' } });
    performance.measure(
      'island:hydrate:analytics-chart',
      'island:hydrate:start',
      'island:hydrate:end'
    );
    // Visible in DevTools Performance panel → Timings track
  });
}

DevTools Profiling Checklist

  1. Network: Enable 3G throttling; confirm Transfer-Encoding: chunked in response headers. Multiple data received events in the Waterfall confirm streaming is active.
  2. Performance timeline: Record a page load. Filter tasks > 50 ms. Islands should produce many short tasks rather than one long blocking task.
  3. Memory: Take heap snapshots before and after island unmount. Verify that island contexts are garbage-collected.
  4. React Profiler / Svelte DevTools: Confirm hydration phases are fragmented, not a single synchronous reconciliation pass.

Framework-specific diagnostic workflows are in Implementing Suspense Boundaries in Next.js 14 and Optimizing Qwik Resumability for Large Datasets.

Decision Guidance

Choosing between full hydration, islands, and resumability depends on the interactivity profile of the page, the team’s familiarity with each framework’s compilation model, and the acceptable tradeoff between initial load cost and per-interaction latency.

Scenario Recommended Approach Rationale
Mostly static content (blog, docs, marketing) with a few interactive widgets Astro islands with client:visible Zero JS shipped for static regions; progressive enhancement for widgets
SaaS dashboard with many real-time data panels Next.js App Router with fine-grained <Suspense> React ecosystem, RSC data colocated with components, streaming handles panel independence
E-commerce product page — fast perceived load critical Qwik or Astro Resumability or static-first ensures TTI ≈ 0; product detail is high-stakes for conversion
Content site with compile-time reactivity requirement SvelteKit Compile-time islands eliminate VDOM overhead; minimal runtime for reactive regions
Multi-team micro-frontend shell Astro or custom island runtime Framework-agnostic island boundaries allow each team to own their component runtime
Existing React SPA migrating incrementally Next.js App Router + RSC Incremental adoption: add 'use server' / 'use client' boundaries without a full rewrite

Unlike micro-frontends, which compose distinct applications at the route or shell level, islands operate within a single page context and do not require a runtime router or cross-application communication protocol. Choose islands when the interactivity requirement is per-component rather than per-route.

Cross-Framework State Synchronisation

Enterprise pages frequently mix islands from different runtimes — a React analytics panel alongside a Svelte search widget, for example. Framework-specific state managers (Zustand, Svelte stores, Qwik signals) must not leak across island boundaries; coupling creates hydration conflicts and prevents independent deployment.

The reliable pattern is a neutral transport layer using the DOM event bus. As detailed in Server-Client Boundaries & State Synchronization, each island publishes state changes to a BroadcastChannel or CustomEvent target; subscribers react without sharing store references.

// Neutral island event bus — no framework dependency
// Instantiate once per page in a shared module

const ISLAND_CHANNEL = new BroadcastChannel('island_sync');

/** Publisher — call from any island (React, Svelte, Qwik, etc.) */
export function publishIslandEvent(type, payload) {
  ISLAND_CHANNEL.postMessage({
    type,            // e.g. 'FILTER_CHANGED'
    source: 'react-analytics', // prevents echo in the sender
    payload,
    ts: performance.now()
  });
}

/** Subscriber — attach in onMount / useEffect / $ with cleanup */
export function subscribeIslandEvents(handler) {
  const onMessage = (event) => {
    // Guard: ignore own messages to avoid re-entrant updates
    if (event.data.source !== 'local') handler(event.data);
  };
  ISLAND_CHANNEL.addEventListener('message', onMessage);
  return () => ISLAND_CHANNEL.removeEventListener('message', onMessage);
}

Failure Modes & Anti-Patterns

1. Cascade Hydration

Marking too many components client:load or 'use client' collapses islands into a de-facto monolithic hydration pass. The symptom is a single long task (> 200 ms) in the Performance timeline immediately after DOMContentLoaded. Fix: audit with client:visible or client:idle for below-fold components; measure TBT before and after.

2. Hydration Mismatch

Server-rendered HTML diverges from what the client hydration pass expects — typically caused by non-deterministic values (random IDs, Date.now(), browser-only APIs accessed during SSR). React throws a hydration warning; Qwik silently resumes from stale state. Fix: defer non-deterministic values to useEffect/onMount; use suppressHydrationWarning only for known safe cases (timestamps, localised strings).

3. Over-Fragmentation

Splitting a page into dozens of tiny islands increases network round trips (each island’s JS chunk is a separate request) and inflates CLS from excessive skeleton placeholders. Fix: group logically related interactive elements into a single island; use content-visibility: auto for off-screen static regions instead of fragmentation.

4. Boundary Leaks

Client code imports a module that contains server-only logic (database clients, secret environment variables) because the 'use server' / 'use client' boundary was placed at the wrong level. Next.js will throw a build error; Astro will warn at runtime. Fix: keep all data-fetching logic in server components or +page.server.ts files; never import server modules from client islands.

5. Streaming Backpressure Under Load

High concurrency degrades streaming throughput when the server cannot write chunks fast enough to satisfy open connections. Symptoms: TTFB within acceptable range but LCP degrades under load. Fix: implement connection pooling for database clients, add circuit breakers around slow API calls, and configure a stale-while-revalidate caching layer for segments with tolerable staleness.

Caching Strategy

Layer Cache Directive Rationale
Static shell Cache-Control: public, max-age=31536000, immutable Versioned asset; never changes between deploys
Dynamic streaming segments Cache-Control: s-maxage=60, stale-while-revalidate=300 CDN serves stale while revalidating; avoids TTFB regression from bypass
Island JS chunks Long-term cache with content hash in filename Chunks only change when component source changes
API responses behind islands Short TTL or no-store Data freshness requirement drives this; do not cache personalised payloads at CDN

← Back to Core Islands Architecture & Hydration Models