Server rendering can put a complete article, product page, or dashboard shell on screen before JavaScript finishes downloading. Yet many SSR applications immediately send the same component tree to the browser and ask it to reconstruct every listener, reactive effect, and component instance. The page looks ready while the main thread is still parsing code and hydrating nodes. A tap during that interval may do nothing.
Islands architecture starts with a different question: which regions actually need a client runtime? The server emits useful HTML for the whole route, but only isolated interactive regions become JavaScript applications. Progressive hydration adds time as another boundary: even an interactive island need not hydrate at startup. It can wait until it is visible, the user expresses intent, or a media condition becomes true.
This is not merely lazy-loading components. The architecture must preserve server output, serialize trustworthy state, route events that arrive before activation, schedule work without starving input, and prove that the result remains accessible before JavaScript runs.
Hydration has a CPU cost and a correctness contract
Traditional hydration walks server-created DOM while rendering the client component tree. The framework associates virtual nodes with existing elements, creates component state, installs event handlers, and often initializes effects or subscriptions. The HTML is reused, but the work that would have built the tree still partly occurs.
The cost has several dimensions:
| Cost | What the browser does | User-visible symptom |
|---|---|---|
| Network | Download framework, route, and component modules | Slow activation on constrained links |
| Parse/compile | Parse JavaScript and compile functions | Long tasks before code executes |
| Execution | Recreate components, effects, and listeners | Delayed input and poor INP |
| Memory | Retain runtime objects and reactive graphs | Pressure on low-memory devices |
| Contention | Compete with images, analytics, and third parties | Unpredictable startup |
A 30 KB compressed bundle is not a 30 KB execution cost. Decompression, parsing, imported dependencies, and initialization all consume CPU. Measure transferred bytes, but also inspect main-thread time and long tasks on representative mobile hardware.
Hydration also assumes that the client’s first render matches the server DOM. Mismatches arise from rendering Date.now(), random IDs, browser-only feature checks, locale or timezone differences, stale cached data, invalid HTML repaired by the parser, and state changed between SSR and hydration. Frameworks may warn, discard a subtree, or silently attach behavior to the wrong expectation.
// Server and client must receive the same value for the first render.
export type PriceProps = {
amountInCents: number;
currency: 'USD' | 'VND';
formattedAtBuild: string;
};
export function renderPrice(props: PriceProps): string {
return `<output>${escapeHtml(props.formattedAtBuild)}</output>`;
}
The server should compute unstable presentation inputs once and serialize them. After hydration, the component may refresh them deliberately. Do not hide unexplained mismatch warnings; they indicate a broken handoff contract and can erase the performance benefit through client re-rendering.
Warning
SSR is not automatically interactive. A button visible at 800 ms but hydrated at 3 seconds has an interaction gap. Track when controls become usable, not only when pixels appear.
Islands change the unit of interactivity
An island is a bounded region with server-rendered markup, a client entry module, and serializable inputs. Static content between islands never joins a client component tree. A product page might hydrate image zoom, inventory selection, and the cart independently while leaving its title, description, specifications, and related links as HTML.
The boundaries should follow behavior, not visual rectangles. A navigation menu and its open button belong to one island because they share focus and expanded state. Splitting every button into an island multiplies module requests, serialized payloads, observers, and coordination code. Making the entire route one island removes the benefit.
| Strategy | Initial HTML | Client activation | Typical trade-off |
|---|---|---|---|
| Full hydration | Complete | Entire component tree | Simple mental model, highest startup work |
| Selective hydration | Complete | Framework prioritizes chosen subtrees | Less blocking, often still ships broad runtime/code |
| Progressive hydration | Complete | Subtrees activate over time or by trigger | Better startup, must handle interaction gaps |
| Islands | Complete around isolated apps | Only declared interactive regions | Strong JavaScript boundary, cross-island state needs design |
| Resumability | Complete plus execution metadata | Resume handlers/state without replaying initial render | Minimal reconstruction, more server metadata and framework constraints |
These terms overlap but are not synonyms. Selective hydration chooses which server-rendered subtree hydrates first. Progressive hydration staggers activation according to priority or conditions. Islands define independent client ownership boundaries and may use progressive triggers. Resumability serializes enough execution context to continue without recreating the component tree in the usual hydration pass. Streaming SSR is orthogonal: it changes when HTML arrives, not necessarily how much client code executes.
A loader for visibility, interaction, and media triggers
Keep the bootloader small and make imports statically discoverable by the bundler. Each island module owns a hydrate function and receives only JSON-compatible props. The server can emit markup like this:
<section
data-island="ProductGallery"
data-trigger="visible"
data-props-id="island-props-gallery-42"
>
<!-- complete SSR gallery markup -->
</section>
<script id="island-props-gallery-42" type="application/json">
{"productId":"42","initialImage":0}
</script>
The JSON script avoids executable inline code. Escape < as \u003c during serialization so user content cannot terminate the script element. A strict Content Security Policy should still govern scripts and connections.
The following framework-neutral TypeScript loader supports visible, interaction, and media:(query) triggers. In a Vue island, the imported module can implement hydrate with Vue’s createSSRApp(Component, props).mount(root); a client-only island can use createApp instead, but then it should not pretend to hydrate SSR-owned markup.
type IslandModule = {
hydrate(root: HTMLElement, props: unknown): void | Promise<void>;
};
const modules = {
ProductGallery: () => import('./islands/ProductGallery.js'),
SearchBox: () => import('./islands/SearchBox.js'),
DesktopFilters: () => import('./islands/DesktopFilters.js'),
} satisfies Record<string, () => Promise<IslandModule>>;
type IslandName = keyof typeof modules;
const pending = new WeakMap<HTMLElement, Promise<void>>();
function readProps(root: HTMLElement): unknown {
const id = root.dataset.propsId;
if (!id) return {};
const node = document.getElementById(id);
if (!(node instanceof HTMLScriptElement)) {
throw new Error(`Missing island props: ${id}`);
}
return JSON.parse(node.textContent ?? '{}') as unknown;
}
function activate(root: HTMLElement): Promise<void> {
const existing = pending.get(root);
if (existing) return existing;
const name = root.dataset.island as IslandName | undefined;
const load = name ? modules[name] : undefined;
if (!load) return Promise.reject(new Error(`Unknown island: ${name ?? ''}`));
const task = load()
.then((module) => module.hydrate(root, readProps(root)))
.then(() => {
root.dataset.hydrated = 'true';
});
pending.set(root, task);
return task;
}
function onVisible(root: HTMLElement): void {
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer.disconnect();
void activate(root);
},
{ rootMargin: '200px 0px' },
);
observer.observe(root);
}
function onInteraction(root: HTMLElement): void {
const warm = () => void activate(root);
root.addEventListener('pointerenter', warm, { once: true, passive: true });
root.addEventListener('focusin', warm, { once: true });
root.addEventListener('touchstart', warm, { once: true, passive: true });
}
function onMedia(root: HTMLElement, query: string): void {
const media = window.matchMedia(query);
const check = () => {
if (!media.matches) return;
media.removeEventListener('change', check);
void activate(root);
};
check();
if (!media.matches) media.addEventListener('change', check);
}
for (const root of document.querySelectorAll<HTMLElement>('[data-island]')) {
const trigger = root.dataset.trigger ?? 'visible';
if (trigger === 'interaction') onInteraction(root);
else if (trigger.startsWith('media:')) onMedia(root, trigger.slice(6));
else onVisible(root);
}
rootMargin begins downloading shortly before an island enters the viewport. Interaction warm-up on hover, focus, or touch usually finishes before the resulting click. It is only a latency optimization, however, not a correctness guarantee. A keyboard user may press Enter immediately after tabbing, and a slow network may outlast any hint.
Production loaders therefore need an event policy. Keep native links and form submissions functional before hydration whenever possible. For behavior that cannot degrade to HTML, capture relevant events, prevent their default only when necessary, queue a minimal event description, activate once, and replay through an application command after handlers are installed. Do not retain raw DOM events indefinitely or redispatch every event generically: event targets may disappear, default actions can run twice, and pointer coordinates can become stale.
Tip
Prefer platform behavior over replay machinery. A server-rendered <a href>, <form action>, <details>, or checkbox remains useful during activation and under script failure.
State, scheduling, and cross-island communication
Serialized props are a public boundary. Include the smallest state needed to reproduce the server view: stable IDs, selected values, locale-sensitive strings already rendered, and version information when cache skew is possible. Exclude secrets, authorization decisions, database records with unused fields, class instances, functions, and cyclic graphs. Validate decoded data at the island boundary; TypeScript annotations do not validate JSON.
Avoid serializing the same large object into ten islands. Put immutable shared route data in one JSON payload referenced by ID, or expose a small read-only client cache. Mutable state requires an explicit owner. A cart service can publish domain events such as cart:item-added; islands should not reach into each other’s component instances or DOM internals.
Scheduling matters after a trigger fires. Above-the-fold controls and interaction-triggered modules deserve immediate work. Below-the-fold enhancement can wait for visibility. Speculative hydration can use scheduler.postTask where available, with a fallback, but requestIdleCallback is not a deadline guarantee and may be delayed indefinitely on busy pages. Limit concurrent imports and hydration tasks so several newly visible islands do not create one large CPU burst.
A practical priority order is:
- Preserve native navigation, form submission, and focus behavior.
- Activate the island involved in current user input.
- Activate visible controls likely to receive input next.
- Prefetch near-viewport modules when network conditions permit.
- Hydrate decorative or analytics enhancements last, or never.
Media triggers need lifecycle decisions too. If desktop filters hydrate at (min-width: 64rem) and the viewport later shrinks, destroying the island may discard state and focus. Most systems activate once and let responsive CSS hide or rearrange the result. If deactivation is required, the module needs an explicit dispose contract.
Accessibility, testing, and metrics
An island’s SSR state is its no-JavaScript and pre-hydration state. Test it as a real interface. The DOM must have sensible landmarks, labels, button types, link destinations, form actions, validation messages, and reading order. Do not render a clickable <div> and hope hydration supplies keyboard semantics later. If a disclosure is closed on the server, aria-expanded, the controlled panel, and focus order must already agree.
Focus is especially fragile. Hydration must not replace the focused node, reset an input a user has started editing, or move focus merely because a module loaded. Stable keys and matching markup help frameworks reuse elements. Announce meaningful asynchronous results through an appropriate live region, but do not announce hydration itself.
Test at four layers:
| Layer | Assertion |
|---|---|
| Server HTML | Content and native actions work with JavaScript disabled |
| Hydration contract | No mismatch warnings for fixed fixtures, locales, or timezones |
| Trigger behavior | Modules stay unloaded until visibility, interaction, or media conditions |
| End-to-end | Early clicks, keyboard input, slow network, resize, and script failure remain usable |
Unit tests can stub IntersectionObserver and matchMedia. Browser tests should throttle CPU and network, interact before activation completes, and fail on hydration warnings captured from the console. Also verify that each island module is a separate chunk and that static pages do not accidentally import the full application entry.
Measure outcomes rather than the architectural label. Track JavaScript transferred and executed, total blocking time, long tasks, INP, LCP, memory, hydration duration per island, trigger-to-interactive latency, activation count, and the percentage of islands never activated. The last metric is powerful: code never requested is the clearest islands win. Compare field data by device and route because a fast development laptop hides the cost.
When a SPA is the better architecture
Islands are strongest on content-led routes with pockets of interaction: documentation, commerce detail pages, news, portfolios, and marketing surfaces. They are less compelling when nearly every region shares rapidly changing client state, navigation is mostly in-app, offline operation matters, or users spend a long session manipulating data.
A design tool, mail client, collaborative editor, or operations console may be simpler as an SPA. Forcing it into dozens of islands can produce duplicated stores, awkward event buses, inconsistent navigation state, and more coordination code than hydration savings. SSR can still provide an initial shell or selected routes, but one client application may be the honest ownership boundary.
Choose from measured interaction density and product behavior:
| Prefer islands/progressive hydration when | Prefer an SPA when |
|---|---|
| Most route content is readable HTML | Most screen regions are continuously interactive |
| Islands have limited, explicit dependencies | Features share one rich mutable state graph |
| Search and first-load performance dominate | Long authenticated sessions dominate |
| Native links/forms provide useful fallback | Offline transitions and optimistic updates are core |
| Many visitors never use optional widgets | Nearly every user loads nearly every feature |
Architecture should remove work, not rename it. If every island activates immediately and imports one shared mega-bundle, the system has recreated full hydration with extra plumbing.
Takeaways
- Hydration reuses HTML but still pays network, parse, execution, memory, and scheduling costs.
- Server and client output must match; serialize unstable inputs once and validate the handoff.
- Islands define ownership boundaries, while progressive hydration decides when each boundary activates.
- Visibility, interaction, and media triggers need a small loader, static import map, and deliberate fallback behavior.
- Event replay is a last-mile correctness tool; native HTML behavior is usually safer.
- Measure trigger-to-interactive latency, executed JavaScript, INP, and islands that never activate.
- Use an SPA when the product is genuinely one dense, stateful client application.