Islands and Partial Hydration
Ship JavaScript only for the regions that are genuinely interactive. The saving is real; the cost is a component model that must declare its boundaries.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Why is the whole page being hydrated when only three things on it do anything?
Someone is reading an article that happens to contain a search box and a subscribe form. They want to read, and occasionally to type.
The page is one component tree with one root, so hydration walks the tree. That is how the framework works, and the alternative would mean maintaining several independent applications on one page.
Most of the tree is content. Paragraphs, headings, images and links are already fully functional as HTML, and hydrating them attaches nothing, changes nothing and costs a full traversal (Hydration).
- Most of the tree is content. Paragraphs, headings, images and links are already fully functional as HTML, and hydrating them attaches nothing, changes nothing and costs a full traversal (Hydration).
- The bundle is sized by the tree, not by the interactive part of it. A page with two widgets downloads the framework runtime, the router, the component library and everything the layout imports (Bundle Analysis).
- Interactivity for the widget the user wants is gated behind hydration of everything above it, so the search box becomes usable only after the article has been walked (Long Tasks).
- On a low-end device the dominant cost is parse and execute, and neither of those is reduced by the content being simple. Simple components are still components (The Real Cost of JavaScript).
- The failure is invisible in any paint-based measurement. The page painted early and looks excellent; what is expensive is the part nobody has a screenshot of (Interaction Responsiveness).
What is actually happening
In the browser, not in the framework.
- The page is rendered to HTML as usual — by a server, or at build time; islands is a decision about JavaScript, not about where the markup came from (Server-Side Rendering, Static Site Generation).
- Interactive regions are marked explicitly. Each marked region becomes an independently mounted root with its own small bundle, and everything outside those regions ships no JavaScript at all (Drawing Component Boundaries).
- Each island hydrates on its own trigger: immediately, when it scrolls into view, when the browser is idle, on first interaction, or only at a given viewport. That trigger is part of the declaration, which is why it has to be declared (content-visibility).
- Because the static majority never becomes a component on the client, there is no tree to walk over it, no listeners to attach and no state to reconstruct — the saving is that the work was not shipped rather than that it was made faster (Code Splitting).
- Islands do not share a component tree, so they do not share context, and props cannot flow between them. Anything two islands both need becomes an explicit channel: a shared store outside both, a custom DOM event, or the URL (Who Owns This State?).
- Server-side, an island is usually rendered by the same renderer as the rest of the page, with its props serialized into an attribute so the client can mount it with the same inputs (Hydration).
What this makes the browser do
And which of it is avoidable.
- Parse and lay out the complete document, exactly as with any server-rendered page (Tree Construction).
- Download and execute only the code the marked regions need, plus whatever runtime those regions require (Bundle Analysis).
- Hydrate each island independently — several small walks over small subtrees rather than one long walk over the document.
- Avoidable: nothing, by construction. That is the point of the strategy: the avoidable work was not sent (Hydration).
- New: a small amount of coordination work, since each island mounts separately and any shared state has to be read from somewhere outside all of them (State Synchronization).
Two ways to spend a bundle on the same page
The picture is the argument. Both versions of the page send the same HTML and paint at the same moment. What differs is what the client is asked to take ownership of: in one, the document; in the other, three regions of it.
Note that the saving is not a clever optimisation of hydration. It is the absence of hydration for regions that never needed it, which is why the effect scales with how much of the page is content rather than with how well the framework is tuned.
What it does to the milestones
Read this against the Server-Side Rendering and Static Site Generation timelines. The first three rows are identical — islands is a decision about JavaScript, and it changes nothing about where the HTML came from or when it paints. The interesting rows are the last three, and they move for a mechanical reason rather than a clever one: the work was not made faster, it was not shipped.
The Interactive row also stops being a single number. Each island becomes usable when its own bundle arrives, so a page with a header widget and a below-the-fold chart has two different answers — and reporting one whole-page figure hides exactly the improvement the strategy produced.
- HTML arrives — Identical to whichever strategy produced the markup. Islands does not change this row.
- Data ready — Already in the HTML for static regions. An island that needs its own data fetches after it mounts.
- Content visible — As early as static generation or streaming. Nothing is different yet — the difference is entirely in what comes next.
- JS downloaded — The decisive row. Only the islands ship code, so this lands far earlier than the four units a full application bundle costs.
- Hydration — Per island, and only for islands. A page of text with two widgets hydrates two widgets rather than a document.
- Interactive — The earliest in the module — and per island rather than per page, which is why a single figure here is the least useful number on the chart.
Units are ordering, not duration. Compare this row set against Server-Side Rendering: the same first three rows, and roughly half the distance from Content visible to Interactive.
The bill: boundaries and communication
Islands are not free and the cost is not mainly tooling. It is that the component model now carries a distinction ordinary component models do not have: some components can be interactive and some cannot, and which is which has to be declared at the boundary rather than discovered by using a hook.
The sharpest edge is shared state. In a single tree, two components that both need the cart count share a provider and nobody thinks about it. As two islands they have no common ancestor, so the shared value has to live outside both — and the decision about where, and who owns writes, becomes an explicit piece of architecture instead of an implicit one.
<!-- Two islands. There is no common React/Vue root, so there is no
provider above them and no context to read. -->
<div data-island="CartBadge"></div>
<div data-island="AddToCart" data-props='{"sku":"A-19"}'></div>
// Inside each island:
const { count, add } = useCart() // a hook backed by a provider
// that only exists inside one root
// Result: each island creates its own independent store.
// AddToCart increments its copy; CartBadge never hears about it.<!-- The value lives in one place both islands can reach, and the
server rendered the initial count into the markup so the badge is
correct before any island hydrates. -->
<div data-island="CartBadge" data-props='{"initial":3}'>
<span aria-live="polite">3 items</span>
</div>
<div data-island="AddToCart" data-props='{"sku":"A-19"}'></div>
// cart-store.ts — a module, imported by both island bundles.
// One owner for writes, a subscription for reads.
export const cart = createStore({ count: 3 })
// AddToCart: cart.set(c => ({ count: c.count + 1 }))
// CartBadge: cart.subscribe(c => setCount(c.count))The first version fails silently: both islands compile, both run, and the only symptom is that the badge never updates. The second makes the channel a real, named module with one owner for writes — and because the initial count is rendered into the markup, the badge is correct before either island hydrates rather than after. The cost is that the channel is now something you designed and have to maintain, which is the honest price of not having a shared tree.
How to build it
Most important first.
- Draw the boundary by asking what each region does without JavaScript. A region that is complete as HTML is content; a region that needs a listener to be useful is an island. Do not draw it by visual grouping (Drawing Component Boundaries).
- Prefer platform behaviour before declaring an island at all. A disclosure that uses
details, a navigation that uses links, a form that posts — none of these needs to be an island because the browser already implements them (What Native Elements Already Do). - Choose the hydration trigger deliberately per island. A cart widget in the header is immediate; a comment box below the fold is on visibility; a heavy chart is on interaction with a real, focusable placeholder (Lazy Loading).
- Keep islands small and few. Ten small islands with overlapping dependencies can ship more code than one medium one, because each pulls its own graph (The Module Graph).
- Make cross-island state explicit and put it somewhere both can reach: the URL for anything shareable, a tiny shared store for anything else. Do not simulate a shared tree (The URL Is Application State).
- Reserve space for any island that changes size when it hydrates, since it is mounting into a page a person is already reading (Visual Stability).
- Re-audit the boundaries periodically. The characteristic drift is that "interactive" expands island by island until the page is a full application again with extra bookkeeping.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- The strategy is unusually good for accessibility when it is done right: the static majority is real HTML that works before, during and without JavaScript, so keyboard navigation and screen-reader access to the content never depend on a bundle (The Accessibility Tree).
- It is unusually bad when the static markup is a placeholder. A
divwaiting to become a combobox is announced as nothing, and a keyboard user cannot reach it at all — the island must render a working, focusable control on the server and enhance it on mount (Semantics Before ARIA). - Deferred hydration triggers must include keyboard paths. "Hydrate on hover" excludes every user who does not hover; the trigger set has to include focus, and ideally the island should hydrate on idle regardless (Keyboard Operability).
- Islands mounting at different moments means the page changes shape several times after paint. Anything that moves needs reserved space, and anything that changes meaning needs announcing (Live Regions and Announcement).
- Focus must never be stolen by an island that finishes mounting. The user may be typing in a different island entirely (Focus Management).
- Cross-island announcements need one owner. Two islands each maintaining their own live region will talk over each other; a single shared status region, owned by the page, is the workable pattern (Live Regions and Announcement).
What can go wrong
- Island sprawl: every region becomes an island, each ships the runtime, and the total exceeds what a single bundle would have been (Code Splitting).
- Shared state smuggled through a global, so two islands are coupled by something neither declares and a change to one breaks the other (Who Owns This State?).
- An island that hydrates on visibility and never becomes visible on a short viewport, so a control the user can reach by keyboard never activates (Keyboard Operability).
- A widget whose static markup is a placeholder rather than a working control, so the page is broken rather than degraded until the island loads (Native Forms First).
- The mitigation failing: deferring hydration to save main-thread time, and deferring the one thing the user came to interact with.
- Duplicate dependencies across islands, invisible in a per-island size report and obvious only in a whole-page one (Bundle Analysis).
- Two islands hydrate in an order determined by bundle arrival rather than by document order, so a control near the bottom can become interactive before one near the top (Code Splitting).
- A user interacts with an island whose bundle has not arrived; unless the static markup is a working control, the input is discarded (Hydration).
- Two islands write to the same shared store during their own mount, and the last one to hydrate wins (State Synchronization).
- An island hydrates while another island's update is changing the layout around it, so its own measurement is taken against geometry that is about to change (Layout Thrashing).
- Island props are serialized into the document, usually as an attribute or an adjacent script tag, and are therefore public. The same projection discipline server rendering needs applies per island (Server-Side Rendering).
- A smaller client surface is a smaller injection surface, but the sinks that remain are the same ones: anything an island renders as raw HTML is an XSS sink regardless of how little code surrounds it (Cross-Site Scripting).
- Islands make it tempting to treat "this region did not hydrate" as a control. It is not — the markup is in the document either way, and the server still decides what a user may do (Authorization-Aware UI).
- A third-party widget adopted as an island still runs with the page's full authority; isolating it as an island is an architectural boundary, not a security one (Third-Party Scripts and the Supply Chain).
- "Islands are just code splitting." Code splitting defers loading a part of one tree; islands mean most of the page never becomes part of a tree at all. The second removes work, the first reschedules it (Code Splitting).
- "Islands make hydration faster." They make hydration smaller. The per-node cost is unchanged; there are far fewer nodes (Hydration).
- "This is only for content sites." It is best for content-heavy pages, and the boundary question is worth asking on any page — most applications have a large static shell nobody has looked at.
- "More islands means more granular, so better." Each island carries fixed overhead and pulls its own dependency graph. Past a point, more islands is more bytes.
- "An island can just import the shared context." There is no shared tree to provide it. That is the constraint, and pretending otherwise produces two independent copies of the same state (State Synchronization).
Measuring it, and what changes in the field
- JavaScript downloaded for the route, compared against the same page built as one tree. This is the metric the strategy exists to move, and it should move substantially or the boundaries are wrong (Bundle Analysis).
- Script execution before first interaction. Islands should show several short blocks rather than one long one, and a page that still shows one long one has an island that is really the whole page (Long Tasks).
- Time from content visible to each island responding, per island — the whole-page interactivity number is the least useful measurement here because it hides the point (Interaction Responsiveness).
- Duplicate modules across island bundles, which is the specific waste this architecture invites (The Module Graph).
- Whether deferred islands are actually being hydrated in the field. An island that hydrates on visibility and is never visible is a control that does not exist for those users (Real User Monitoring).
- On a low-end device this is the strategy with the largest effect, because it removes parse and execute rather than making them faster (The Real Cost of JavaScript).
- On a slow network the saving is bytes on the critical path to interactivity, which is exactly where bytes hurt most.
- On a content-heavy page the ratio is excellent; on an application-heavy page it approaches zero, because almost everything is an island and the bookkeeping is pure overhead.
- With many islands on one page, per-island request overhead and duplicated dependencies can eat the saving — the shape has a floor (Code Splitting).
- On a long session the advantage narrows, since a client-rendered application amortises its bundle across navigations while an island page may reload the document (MPA vs SPA).
- The earliest interactivity in the module, bought with a component model that must declare where interactivity begins and ends — a constraint that shows up in every component you write, not only the ones near a boundary.
- No shared component tree, so cross-island communication becomes explicit plumbing: a store, an event, or the URL. Explicit is better than implicit and it is still more code than a prop.
- Framework and component-library support is uneven, because many are built on the assumption of a single client root (Choosing a Framework).
- The boundary needs maintaining. It is a decision that decays quietly as features are added, and nothing fails when it does.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe underlying idea — mark the interactive regions and ship code only for those — is a property of the page rather than of any tool, and can be built by hand with plain markup and a few small scripts in any stack.
- FRAMEWORK-SPECIFICThe ergonomics vary sharply: Astro makes the island an explicit directive with named hydration triggers, Marko and Qwik derive the boundaries during compilation, and a framework that assumes a single client root can only approximate this with multiple manual mount points and no shared tooling for props or triggers.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Software Design: island boundaries are module boundaries with a runtime consequence, and they decay the same way module boundaries decay — one convenient import at a time.