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.
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
Promiseresolution.
Execution Pipeline
The ordered lifecycle from request to interactive island:
- Request arrives at the server (Node.js, edge runtime, Deno, Bun).
- Data layer executes — database queries, API calls, RSC flight data.
- Static shell streams — the HTML
<head>and above-the-fold markup flush viaTransfer-Encoding: chunked; TTFB is recorded here. - Chunk boundaries mark deferred regions —
<Suspense>fallbacks (React),{#await}blocks (SvelteKit), orq:hostserialisation (Qwik) act as placeholders. - Async data resolves — the server flushes each resolved chunk, replacing placeholder content inline.
- HTML parser builds DOM progressively — the browser renders content as it arrives; no JS execution required at this stage.
- Hydration scheduler activates islands — based on viewport intersection, idle time, or explicit user events.
- 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:
- SSR serialises component state →
q:stateattributes written into HTML. - HTML streams to client; DOM builds; page is visually complete.
- Zero JavaScript executes on load.
- User triggers an event → Qwik fetches the matching
onClick$chunk (~1–3 KB). - 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
- Network: Enable 3G throttling; confirm
Transfer-Encoding: chunkedin response headers. Multipledata receivedevents in the Waterfall confirm streaming is active. - Performance timeline: Record a page load. Filter tasks > 50 ms. Islands should produce many short tasks rather than one long blocking task.
- Memory: Take heap snapshots before and after island unmount. Verify that island contexts are garbage-collected.
- 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 |
Related
- Astro Islands and Client Directives — directive taxonomy,
client:onlytradeoffs, and multi-framework island composition. - Next.js App Router Streaming Patterns — RSC Flight protocol,
<Suspense>configuration, and edge runtime routing. - Qwik Resumable Architecture — state serialisation, fine-grained lazy loading, and benchmarks versus traditional hydration.
- SvelteKit Component Islands — compile-time reactivity, scoped store patterns, and streaming
{#await}blocks. - Server-Client Boundaries & State Synchronization — cross-island event buses, boundary contracts, and consistency guarantees for mixed-runtime pages.