cf.completefrontend
My progress
FUNDAMENTALS · MODULE 7

Islands and partial hydration

Ship a page as static HTML and send JavaScript only for the few parts that need it, each waking up on its own trigger, so content pages stay fast on slow phones instead of paying to hydrate the whole app.

The problem: paying for a whole app to read an article

Picture a news article page: a header with a menu, a headline, a photo, 1,500 words of text, share buttons, a newsletter signup, comments, and a footer. Most of the page is words and pictures. Only four small parts react to the reader.

The team built the site with a popular framework and server-side rendering (SSR), so the HTML arrives complete and the text shows quickly. Then the page downloads 420 KB of compressed JavaScript. That bundle holds the framework, the router, every component on the page, and the data used to render them, sent a second time as JSON.

420 KB compressed is about 1.5 MB once unzipped, and a mid-range Android phone gets through roughly 1 MB of JavaScript per second of main-thread work (the rule of thumb from the estimation lesson). So the browser spends about 1.5 seconds parsing and running that code, after about two seconds of download on a slow 4G link. Meanwhile the reader taps the menu button and nothing happens. And the headline, the paragraphs, and the footer never needed JavaScript at all.

Why the whole page pays: a quick recap of hydration

Hydration is the step where the framework, running in the browser, walks the server-made HTML, rebuilds its component tree in memory, and attaches event listeners. The key fact: classic hydration works on the whole tree. To attach one handler to the menu button, the framework runs every component, because it cannot know which parts are static without running them.

  • Download cost. Every component's code ships, including the many that only print text.
  • Data cost. Props for every component ship again as serialized JSON, so the article's text travels twice: once as HTML, once as JSON.
  • CPU cost. The framework runs every component in the browser to match it with the existing HTML.
  • Waiting cost. Until the whole tree finishes, no part of it responds. A tap during hydration is lost or delayed.
Two wireframes of the same news article page side by side. Before, with full hydration: every region, the header, headline, photo, text, share buttons, signup box, and comments, ships code, 420 KB in total, and all of it runs at startup. After, with islands: only four regions ship code, the menu button, the share bar, the signup box, and the comments, 87 KB at startup and 147 KB if the reader scrolls to the end; the headline, photo, and text are plain HTML.before: full hydrationafter: islandsnews.example/articlenews.example/articlemenusharesignupcomments420 KB, all runs at startup87 KB at startup, 4 islands
Same pixels, very different costs. On the left the whole page is one app that must hydrate before anything responds; on the right only the hatched parts ship JavaScript, and each wakes on its own.

The idea in one sentence

What if the page shipped as plain HTML, and only the four interactive parts got JavaScript, each loaded on its own and started at the moment it is needed? That is the islands architecture, built on partial hydration: hydrating only some parts of a page. A related idea, resumability, tries to skip hydration entirely. This lesson covers both, and what each saves.

Check your understanding · Exercise 1

A server-rendered article page uses classic full hydration. Which of these does the browser pay for, even for the parts of the page that never change? Select all that apply.

The mental model: a static sea with islands

Think of the page as a sea of plain HTML. The sea is made once, on the server or at build time, and it never needs JavaScript. Dotted across it are a few islands: small, self-contained interactive components.

Each island has its own little bundle and starts up on its own. An island knows nothing about the sea around it, and usually nothing about the other islands.

Where the name comes from

Katie Sylor-Miller, then a frontend architect at Etsy, coined the term "component islands" during a 2019 meeting with Jason Miller. In August 2020 Jason Miller, the creator of Preact, wrote the post "Islands Architecture", which gave the pattern its widely used name and description. Server pages with a sprinkle of jQuery were islands in spirit; what was new was doing it with modern components that render on the server and, only where needed, run again in the browser.

Three properties that define an island

  1. It is its own root. Each island is mounted separately, with its own small component tree. A crash in one island does not take down the others.
  2. It is server rendered first. The island's HTML is already in the page, so the reader sees a real button, a real form, real comment text, before any JavaScript runs.
  3. It loads on its own schedule. Each island chooses when to load: right away, when the browser is idle, when it scrolls into view, or when a media query matches. Some never load at all.
An architecture picture. The top zone is the page, plain HTML with no JavaScript: text, images, and links cost 0 KB. Inside it sit four separate island roots: Menu, on load, 15 KB; Share bar, on idle, 10 KB; Signup, on visible, 15 KB; Comments, on visible, 45 KB. The bottom zone holds shared chunks downloaded once: react-dom, 60 KB, fetched with the first island that wakes, and the island loader, 2 KB, inline in the HTML. Every island points to react-dom.The page: static HTMLtext, images, links: 0 KBMenuload, 15 KBShare baridle, 10 KBSignupvisible, 15 KBCommentsvisible, 45 KBShared chunks, downloaded onceisland loader2 KB, inlinereact-dom60 KB, first island
Each island is its own root with its own trigger and bundle. What they share, the framework runtime, is downloaded once, with whichever island wakes first.

What it is not

Islands are not code splitting. Code splitting cuts one app's bundle into chunks, but the app is still one tree and still hydrates as a whole (or at least from its root down). Islands remove the single tree: the static parts have no client code at all, not even lazily loaded code.

Islands are not a client-side app either. Navigation is usually a normal full page load (a multi-page app, or MPA), not a client-side router; the spa-vs-mpa lesson covers that trade-off.

The same page, two architectures.
QuestionFull hydration (SSR app)Islands
What is the unit of hydration?The whole page, from the rootEach island, separately
What ships for static text?Its component code and its propsNothing: it stays plain HTML
When does a part become interactive?When the whole tree has hydratedWhen its own trigger fires and its own code has run
How do parts share state?Context, a store, props from the rootOnly through something outside the components (a shared store, events, the URL)
How does navigation work?Usually a client-side routerUsually full page loads (MPA)

Check your understanding · Exercise 2

On a product page, which of these are good candidates to stay as plain static HTML with no island? Select all that apply.

How islands work, step by step

Astro is the best-known islands framework, so we will follow one article page through it. The same steps apply, with different names, in Fresh, Eleventy's is-land, and others. Here is the page. Every component is rendered on the server; only the three with a client: directive become islands.

text
---
// src/pages/blog/[slug].astro (Astro)
import Header from "../../components/Header.astro";
import MenuButton from "../../components/MenuButton.tsx";
import ShareBar from "../../components/ShareBar.tsx";
import Comments from "../../components/Comments.tsx";
import Footer from "../../components/Footer.astro";
const post = await getPost(Astro.params.slug);
---
<Header>
  <MenuButton client:load />
</Header>
<article>
  <h1>{post.title}</h1>
  <Fragment set:html={post.html} />
</article>
<ShareBar client:idle url={post.url} />
<Comments client:visible postId={post.id} />
<Footer />
The part between the --- fences runs on the server only. Below it is the page template. MenuButton, ShareBar, and Comments are React components; Header and Footer are Astro components, which never ship JavaScript.

One detail surprises people: a React component used without a client: directive is still rendered, but only to HTML. Its code never reaches the browser. The directive, not the component's framework, decides whether something is an island.

The six steps

  1. Render everything to HTML. At build time or per request, Astro runs every component on the server, including the React ones.
  2. Wrap each island. Around each component with a directive, Astro writes a wrapper element, astro-island. Its attributes record where the component's code lives, where the framework's client renderer lives, the props (serialized as JSON), and the directive.
  3. Bundle per island. The bundler builds one small entry chunk per island component. Code shared between islands, like react-dom, goes into a shared chunk that is downloaded once, however many islands use it.
  4. Paint the HTML. The browser paints the page like any static page; buttons and comments are already real HTML. A tiny inline script defines the astro-island element.
  5. Wait for the trigger. As each astro-island element connects, it reads its directive. load starts at once; idle waits for requestIdleCallback; visible sets up an IntersectionObserver; media checks matchMedia.
  6. Hydrate that island alone. When the trigger fires, the element imports the renderer and the component chunk with a dynamic import(), parses its props, and hydrates its own subtree (for React, a hydrateRoot call on that element). Nothing outside it is touched.
A sequence diagram with four columns: Browser, Loader, CDN, and the Comments island. The browser asks the CDN for the article and gets back HTML with three islands. It paints the page and runs the small inline loader, which waits on an IntersectionObserver. When the comments scroll into view, the loader imports the Comments chunk, 45 KB, plus the shared runtime from the CDN, then hydrates only the comments element, which becomes interactive.BrowserLoaderCDNCommentsGET /articleHTML + 3 islandspaint, run loaderwaits for scrollcomments visibleimport()45 KB + runtimehydrate propsinteractive
The page is readable after the second arrow. Nothing about the comments costs JavaScript until the reader scrolls there; then only that island is fetched and hydrated, with a hydrateRoot on its own element.

What the HTML looks like

The exact attributes change between Astro versions, so treat this as a simplified picture. Everything needed to wake an island later is written into the HTML now.

html
<astro-island
  uid="Z1abc"
  component-url="/_astro/Comments.B7x2.js"
  renderer-url="/_astro/client.Dk91.js"
  props='{"postId":[0,"p_812"]}'
  client="visible"
  ssr>
  <section class="comments">
    <h2>42 comments</h2>
    <ul><li>Great article...</li></ul>
    <button>Load more</button>
  </section>
</astro-island>
Simplified. The child HTML is the server-rendered component, visible before any script runs. The props use Astro's tagged format, where 0 marks a plain value.
The HTML of one island, line by line, with notes. astro-island: a custom element, one per island. component-url pointing at Comments.js: the island's own chunk. renderer-url pointing at client.js: the framework renderer, shared by islands. props with postId p_812: the serialized props, written into the HTML. client="visible": the trigger. A section with 42 comments: the server-rendered HTML, visible right away. The closing astro-island tag.<astro-islanda custom element per islandcomponent-url=".../Comments.js"the island's own chunkrenderer-url=".../client.js"framework renderer, sharedprops='{"postId":[0,"p_812"]}'serialized props, in HTMLclient="visible"the trigger<section>42 comments</section>server HTML, visible now</astro-island>end of the island
Everything needed to wake the island later is a string in the HTML: where the code is, what props to pass, and when to start. The child HTML is what the reader sees until then.

Static children inside an island

An island can wrap static content. If you write MenuButton client:load with a list of links inside it, Astro renders those links on the server and passes them into the island as ready-made HTML (Astro calls this a slot). The links' components do not ship. This lets a small interactive shell, like a collapsible panel, hold a large amount of static content without paying for it.

Props are the opposite. Whatever you pass to an island as props is serialized into the HTML and parsed in the browser. Passing a 200-item array to an island that only shows the first five puts all 200 into the page. Props must also be serializable: you cannot pass a function from the server page to an island, because there is no way to write a function into HTML and revive it in the browser.

Check your understanding · Exercise 3

An Astro page has a React ProductReviews component with no client: directive. What does the browser get? Select all that apply.

Choosing when each island wakes: the client directives

The directive answers one question per island: when is it worth spending its bytes and CPU? Astro gives five answers; other tools use similar ones (Eleventy's is-land has on:visible and on:idle).

A state machine for one island. Server HTML, visible but inert, goes to Waiting for its trigger when the loader runs. Waiting goes to Loading the chunk and runtime when the trigger fires, and Loading goes to Interactive, handlers live, when the code has run. From Waiting, a dashed branch goes to Never loads, if the trigger never fires, for example the reader never scrolls there. The first three states form the dead window: the island looks ready but ignores taps.Never loadsno triggerreader never scrolls thereServer HTMLvisible, inertWaitingfor its triggerLoadingchunk + runtimeInteractivehandlers liveloader runstrigger firescode randead window: looks ready, ignores tapstaps work
The directive decides how long an island sits in Waiting. A longer wait saves bytes for readers who never need it, but widens the window where a reader can tap a button that does nothing yet.

client:load: as soon as possible

The island's code starts downloading right after the page loads, and it hydrates as soon as the code arrives. Use it for controls the reader is likely to touch in the first seconds and that sit above the fold: a menu button, a search box in the header, a cookie banner's buttons.

The mistake: putting client:load on everything "to be safe". Then every island competes for the network and the main thread at the same moment, and you are back to full hydration with extra steps.

client:idle: when the browser has a free moment

The island waits until the page has finished loading and the browser reports idle time through requestIdleCallback (in browsers without it, Astro waits for the load event instead). Since Astro 4.15 you can pass a timeout, as in client:idle={{ timeout: 500 }}, to cap the wait. Use it for things that should work soon but not first: a share bar, a "like" button, a newsletter box near the top.

The mistake: assuming idle comes quickly. On a busy page on a slow phone, the first idle moment can be seconds away. A control the reader reaches for in the first two seconds should not wait for idle.

client:visible: when it scrolls into view

The island sets up an IntersectionObserver and loads only when its element enters the viewport. Since Astro 4.1 it accepts a rootMargin option, as in client:visible={{ rootMargin: "200px" }}, so it can start a little before it is visible. Use it for anything below the fold: comments, a related-posts carousel, a map, an embedded video player. If the reader never scrolls there, the code is never downloaded.

The mistake: using it for something big and important that the reader reaches quickly. The reader scrolls, sees the comments, taps "Reply", and the 45 KB comments chunk has only just started downloading. Starting early with a rootMargin, or keeping the island small, avoids the gap.

client:media: only when a media query matches

The island loads only if a CSS media query matches, for example client:media="(max-width: 50em)". Use it for parts that only need JavaScript on some screens: a hamburger menu that exists only on mobile, a sidebar filter that becomes a drawer on small screens. On desktop the code is never fetched.

The mistake: hiding the element with CSS on the other screens but forgetting that its server HTML is still there. If the desktop layout shows a different version of the menu, make sure the mobile version is hidden there, or readers of assistive technology may meet two menus.

client:only: skip the server, render in the browser

The component is not rendered on the server at all. The page gets an empty slot, and the component renders from scratch when its code loads, as in client-side rendering. You must name the framework, like client:only="react". Use it for components that cannot run on a server, such as ones that read window or localStorage during render.

The mistake: using it for content. The content is missing from the HTML, so it shows late, may shift the layout when it appears, and is not in the page that search engines and link previews read. Astro lets you pass a fallback (an element with slot="fallback") to reserve the space.

No directive, and server islands

No directive means plain HTML: the default, and the cheapest choice. Astro 5 also added server islands with server:defer: a part of the page, like a personalized "Hi, Maya" box, is rendered on the server in a separate request after the main page, so the main page can be cached on a CDN while the personal part is filled in. It echoes Partial Prerendering from the streaming SSR lesson, with one difference: Partial Prerendering streams the dynamic holes into the same response, while a server island is fetched with its own request.

The five Astro client directives, plus the default.
DirectiveStarts loadingRendered on the server?Typical useCommon mistake
(none)NeverYesText, images, links, layoutForgetting a directive on an interactive component
client:loadRight after the page loadsYesHeader menu, search boxUsing it everywhere
client:idleFirst idle moment after loadYesShare bar, like buttonControls needed in the first seconds
client:visibleWhen it enters the viewportYesComments, carousel, mapLarge islands the reader reaches quickly
client:mediaWhen a media query matchesYesMobile-only menu or drawerDuplicate menus left in the HTML
client:onlyRight after the page loadsNoBrowser-only widgetsUsing it for content
Device:
Screen:
On a mid-range phone, slow 4G, the HTML paints at 1.2 s. search box with client:load interactive at 1.8 s; share bar with client:idle interactive at 2.4 s; comments with client:visible interactive at 8.4 s; mobile menu with client:media interactive at 1.9 s. JavaScript downloaded in total: 142 KB.HTML painted at 1.2 s; 142 KB of JavaScript in totalclient:loadsearch boxclient:idleshare barclient:visiblecommentsclient:mediamobile menu0s4s8s12s16s
Hatched: downloading. Shaded: running on the main thread. Dot: interactive. One network and one main thread are shared, so an island that starts early can delay the next one. Make the reader never scroll and watch the comments' 45 KB disappear.

Check your understanding · Exercise 4

Which directive pairings are sensible for a recipe page? Select all that apply.

Partial, progressive, selective: untangling the words

Several terms sound alike and get mixed up, in blog posts and in interviews. They answer different questions. Getting them straight is a quick way to show you understand the topic.

Two questions: which parts, and when

  • Partial hydration answers "which parts?": only some components hydrate, and the rest never ship code. Islands are the best-known form. React Server Components are another: server components never ship, client components do.
  • Progressive hydration answers "when?": the page still hydrates as one tree, but in pieces over time, ordered by priority or triggered by visibility or interaction, instead of all at once. All the code still ships eventually.
  • Selective hydration is React 18's form of progressive hydration. Each Suspense boundary hydrates on its own, and React hydrates the boundary the user is interacting with first. The streaming SSR lesson covers it in depth.
  • Lazy hydration is the informal name for the trigger part: hydrate a component on visibility, idle, or first interaction. Astro's directives are lazy hydration applied to islands. Angular's incremental hydration applies it inside one app tree.

So islands are partial and lazy: only some parts ship code, and each part chooses when. Progressive and selective hydration make full hydration smarter about timing but do not remove code: the static article text is still a component in the tree, still downloaded, still run.

A matrix of five approaches against four properties. Full hydration: ships code for static parts, no per-part trigger, replays everything, runs all JavaScript at startup. Progressive hydration: ships code for static parts, has per-part triggers, replays everything, runs all JavaScript in slices. Islands: no code for static parts, a trigger per island, replays only inside each island, runs only the islands. React Server Components (RSC): no code for server components, triggers through Suspense, replays only client parts, runs only client parts. Resumability: no code for static parts, per-event loading, no replay, about 1 KB at startup.no code forstatic partsown triggerper partno replayon loadJS run atstartupFullnononoallProgressivenoyesnoall, slicedIslandsyesyesper islandislandsRSCyesSuspenseclient partsclient partsResumableyesyesyes1 KB
Progressive hydration only fixes timing; the first column is where the bytes are saved. Islands and Server Components both remove static code, in different shapes; resumability also removes the replay.

Why the difference matters

Timing techniques make the tapped part respond sooner, which helps INP (Interaction to Next Paint, the Core Web Vital for input responsiveness). But total bytes and total CPU stay about the same, just in smaller slices.

Partial hydration cuts the total. If 80 percent of the page is static, 80 percent of the component code never ships and never runs. That is why content sites see large drops in JavaScript when they move to islands, and why apps that are interactive everywhere see little.

Decision How should a server-rendered page become interactive?

Full hydration

Ship the whole app; hydrate from the root in one pass.

Pros

  • Simplest model: one tree, shared state everywhere, client-side navigation.

Cons

  • Ships and runs code for static parts.
  • Nothing responds until the whole tree is done.

Progressive or selective hydration

One tree, hydrated in chunks by priority or trigger (React Suspense, Angular incremental hydration).

Pros

  • Keeps the single-app model; important parts respond sooner.

Cons

  • Total bytes and CPU barely change.
  • Static parts still ship as components.

Islands (partial hydration)

Static HTML by default; marked components ship as separate roots with triggers (Astro, Fresh).

Pros

  • Static parts cost zero JavaScript; each island loads on its own schedule.

Cons

  • State sharing across islands needs extra work.
  • Usually full page navigations; weak fit for app-like pages.

Resumability

Serialize listeners and state into the HTML; load each handler on its event (Qwik).

Pros

  • Tiny startup JavaScript; no replay of components.

Cons

  • New programming model with serialization rules.
  • First interaction may wait for code unless it was prefetched.

What we pick: For content pages (blogs, docs, marketing, news, product pages) start from islands: most of the page is static, so most of the savings are real. For app-like pages (dashboards, editors, inboxes) where almost everything is interactive, full hydration with selective hydration and code splitting is simpler and loses little. Consider resumability when startup JavaScript is the main problem on a large interactive site and the team can adopt a new framework.

Think first

React Server Components also leave some components out of the client bundle. Are they islands?

Resumability: skipping hydration altogether

Islands reduce how much hydrates. Resumability, the idea behind the Qwik framework (created by Misko Hevery, who earlier created AngularJS), asks a sharper question: why hydrate at all?

The server already ran the components. It knows where every listener is and what every piece of state holds. If it writes that knowledge into the HTML, the browser can resume where the server stopped, instead of running the components again to rediscover it.

What hydration replays

To attach a click handler, a hydrating framework needs two things: which DOM node gets the handler, and the handler function itself, with the state it closes over. Neither is in plain HTML. So the framework calls every component function again in the browser and lines the result up with the DOM. That is the replay: redoing work the server already did, to recover information the server threw away.

Islands shrink the replay to a few components. Resumability tries to remove it. The cost moves from "run components on load" to "write more into the HTML, and fetch small pieces of code when they are needed".

How Qwik resumes

  1. The compiler splits code at every $. In Qwik, component$, onClick$, and useTask$ mark boundaries. The Qwik optimizer pulls each marked function into its own tiny chunk with a stable name, so a single click handler can be loaded on its own.
  2. The server writes listeners into attributes. A button's HTML carries an attribute like on:click whose value points at a chunk and a symbol inside it. No code is attached; it is just a string.
  3. The server writes state into the page. The component state and signals the handlers need are serialized into a JSON script block in the HTML.
  4. The browser loads only the Qwikloader. A small script, about 1 KB, adds one listener per event type on the document. Nothing else runs at startup.
  5. On the first click, the loader resolves the handler. The event bubbles to the document. The loader walks up from the target, finds the on:click attribute, imports that chunk, restores the state it needs, and calls the handler.
html
<!-- Simplified Qwik output for a counter -->
<button on:click="q-chunk-a1.js#Counter_onClick_x9">
  Count: <span>3</span>
</button>

<script type="qwik/json">
  { "objs": [3], "subs": [...] }
</script>

<script id="qwikloader">/* about 1 KB: one listener per event type */</script>
Simplified; the real attribute values and JSON shape differ by version. The listener is a pointer to code, and the state lives in the page, so nothing needs to run until the click.
1. The HTML arrives (1.2 s)
1. The HTML arrives, at 1.2 s. Hydration: downloaded 0 KB, ran 0 KB, listeners none. Resumability: downloaded 0 KB, ran 0 KB, listeners none.hydrationresumabilitydownloaded0 KB0 KBran0 KB0 KBlistenersnonenonestep 1 of 5, t = 1.2 s
Both pages look the same: full server HTML, painted, no JavaScript yet.

The catch: the first interaction

If the handler's code is fetched only on click, the click waits for a round trip: 100 ms or more on mobile. Qwik handles this by prefetching likely chunks in the background after load (using modulepreload hints or a service worker, depending on version), so the code is usually in the cache by the time the user clicks. That means resumable apps still download code; they just do not execute it at startup.

Other costs are real too. Everything a handler closes over must be serializable, so the optimizer can move it to its own chunk; capturing an instance of an arbitrary class, or an object from a third-party library, in an onClick$ closure fails. Serialized state makes the HTML larger, $ boundaries are a new habit, and the component ecosystem is smaller than React's.

How to talk about its performance

Qwik's headline claim is that startup JavaScript stays near constant as the app grows. That follows from the design. Whether a real product is faster overall depends on prefetching, serialized state size, and how much code the first interactions pull in. Treat it as a property of the architecture, and measure INP and total bytes before promising a number.

Both ship less JavaScript; they disagree on whether hydration is needed at all.
QuestionIslands (Astro)Resumability (Qwik)
What runs at startup?Each island's code, when its trigger firesOnly the 1 KB loader
Does an island re-run its components?Yes, it hydrates its own subtreeNo, state and listeners are read from the HTML
Granularity of loadingPer island (a component and its children)Per handler or component, split at each $
First interactionInstant if the island already hydratedInstant if the chunk was prefetched, else a round trip
Mixing frameworksReact, Svelte, Vue, Solid islands on one pageQwik components (React via an adapter)
Learning costLow: write normal componentsHigher: $ boundaries and serialization rules

Check your understanding · Exercise 5

Which statements about resumability are true? Select all that apply.

Sharing state between islands, and what it costs

Islands are independent by design. That is their strength until two of them need to agree on something. The classic case is a shop: an "Add to cart" island in the product area and a cart badge island in the header. Click one, and the other must show the new count.

In a fully hydrated React app, both would sit under one CartProvider context and share state for free. Islands have no common parent, and a provider in the server layout does nothing in the browser, because the layout never hydrates.

Four ways to connect islands

Decision How should the add-to-cart island tell the header badge island?

A shared store module

Both islands import the same tiny store (Astro's docs suggest Nano Stores, a few hundred bytes).

The bundler puts the store in a shared chunk, so both islands get the same instance.

Pros

  • Works across frameworks: a React and a Svelte island can read one store.

Cons

  • State lives only in this page; a full page navigation resets it unless you persist it.
  • Server HTML cannot know the store's client-side value.

Custom DOM events

The button island dispatches cart:changed on window; the badge listens.

Pros

  • Zero library code; islands stay decoupled.

Cons

  • No current value: a badge that hydrates late misses earlier events.
  • Event names and payloads are an untyped contract.

The URL, cookies, or storage

Persist the state (a cart cookie, localStorage, a query param) and let each island read it.

The server can read cookies to render the right count in the HTML.

Pros

  • Survives page loads, which islands sites do often.
  • Cookies let the server HTML be correct from the start.

Cons

  • Still needs a change signal (a storage event or a custom event) for live updates.
  • Not for large or fast-changing state.

Merge into one bigger island

Make the header and product area one island under one provider.

Pros

  • Normal framework state sharing, no glue code.

Cons

  • Everything between them now hydrates too, which can undo the savings.
  • A large island is just partial full hydration.

What we pick: For a few values shared by a few islands, use a tiny shared store, and persist it in a cookie if the server should render it (the cart count). Use custom events for fire-and-forget signals like "open the login dialog". Merge islands only when they are close together in the page and share a lot. If many islands need lots of shared state, that is a sign the page is really an app, and full hydration (or React Server Components) may fit better.

A graph of how two islands share a cart count. The AddToCart island, written in React, sets the value in a shared cart store. The CartBadge island, written in Svelte, subscribes to the same store. The store persists the count to a cookie, and on the next request the server reads the cookie and renders the right count into the badge's HTML. A CartProvider placed in the page layout is crossed out: it never runs in the browser.Providerin the layout: never runsAddToCartReact islandCartBadgeSvelte islandcart storecookieserversetsubscribepersistnext requestserver renders thecount into the badge
The store is the only thing both islands import, so the bundler ships it once as a shared chunk. The cookie closes the loop across full page loads, so the badge's server HTML is right from the first paint.

The costs people forget

  • The framework runtime ships once per framework, not once per island. Three React islands share one copy of react-dom. But a page with a React island, a Vue island, and a Svelte island ships three runtimes. Mixing frameworks is a migration tool, not a style.
  • Shared code becomes shared chunks. A utility library imported by four islands is downloaded once, but it is downloaded as soon as the first of those islands wakes. A heavy shared import can turn a light client:load island into a heavy one.
  • Late islands miss early state. An island that hydrates on visible starts with the state in the store at that moment. With events, it misses everything that happened before. Design for "read the current value, then subscribe".
tsx
// src/stores/cart.ts: imported by both islands
import { atom } from "nanostores";

export const cartCount = atom(0);

// AddToCart.tsx (React island)
import { useStore } from "@nanostores/react";
import { cartCount } from "../stores/cart";
export function AddToCart() {
  const count = useStore(cartCount);
  return <button onClick={() => cartCount.set(count + 1)}>Add to cart</button>;
}

// CartBadge.svelte reads the same atom with $cartCount
One store module, two frameworks. The bundler deduplicates the store, so both islands see the same atom.

Check your understanding · Exercise 6

A page has a client:load header badge and a client:visible product island at the bottom. They share a cart store. What should you expect? Select all that apply.

How much JavaScript each approach ships

Numbers make the trade-offs concrete. Take the article page from the first chapter and build it five ways. The sizes below are illustrative but realistic, compressed (gzip or Brotli) as they travel on the wire. To turn them into time we use the two rules of thumb from the estimation lesson: compressed JavaScript is about 3.5 times bigger once unzipped, and a mid-range phone gets through about 1 MB of unzipped JavaScript per second of main-thread work.

The page, priced five ways

  • Full hydration: 420 KB, all run at startup: framework 60 KB, router and shell 50 KB, every component 150 KB, shared libraries 100 KB, and the page data again as JSON 60 KB.
  • Progressive or selective hydration: still 420 KB. The menu responds sooner, but the total work is the same.
  • React islands: 87 KB at startup. React 60 KB, menu 15 KB, share bar 10 KB, loader 2 KB. Comments (45 KB) and signup (15 KB) load only if the reader scrolls to them.
  • Preact islands: 32 KB at startup, because the runtime drops to a few KB.
  • Resumable (Qwik): about 1 KB runs at startup. Handler chunks are prefetched in the background, but nothing runs until a click.
A bar chart, to scale, of the JavaScript an article page runs at startup on a mid-range phone. Full hydration: 420 KB, about 1.5 seconds of main-thread work. Progressive hydration: 420 KB in slices, still about 1.5 seconds. React islands: 87 KB at startup, 147 KB if the reader scrolls to the end, about 0.3 seconds. Preact islands: 32 KB at startup, 92 KB in total, about 0.1 seconds. Resumable: about 1 KB runs at startup, with handlers prefetched.full hydration420 KB runs: about 1.5 s of CPUprogressive420 KB, in slices: still about 1.5 sReact islands87 KB at startup (147 KB if scrolled): 0.3 sPreact islands32 KB at startup (92 KB if scrolled): 0.1 sresumableabout 1 KB runs; handlers prefetched
Solid bars run at startup; dashed outlines are what loads if the reader scrolls to the end. Illustrative sizes, priced with 3.5x unzip and about 1 s per MB on a mid-range phone.
Illustrative numbers for one article page with four interactive parts, using 3.5x unzip and about 1 s per MB on a mid-range phone.
ApproachJS at startupMain-thread work at startupJS if the reader reaches the end
Full hydration420 KBabout 1.5 s420 KB
Progressive or selective420 KB (in slices)about 1.5 s, in pieces420 KB
React islands87 KBabout 0.3 s147 KB
Preact islands32 KBabout 0.1 sabout 90 KB
Resumable (Qwik)about 1 KB runsnear zeroonly handlers that are used, plus prefetch

Two lessons hide in that table. First, the biggest single saving is not loading the static components, which islands and resumability both achieve. Second, once you use islands, the framework runtime becomes the largest line item, which is why islands sites often pick a small framework (Preact, Svelte, Solid) for their islands.

Lab: decide the islands yourself

Below is the same article page with eight components. For each one, choose: static HTML, or an island with a directive. The lab adds up the JavaScript at startup and in total, estimates when each part becomes interactive on a slow phone, and warns you about the classic mistakes: dead buttons, content missing from the HTML, and early code nobody needed. Try to get startup JavaScript under 100 KB with no warnings.

Startup JavaScript: 222 KB (about 0.8 s of main-thread work). If the reader scrolls to the end: 222 KB. 6 warnings; startup JavaScript 222 KB.

  • Reading progress bar reads window during render, so it cannot render on the server: use client:only.
  • Headline and article has nothing to click: its 40 KB is wasted.
  • Photo carousel is below the fold: client:visible would skip its 25 KB for readers who never get there.
  • Newsletter signup is below the fold: client:visible would skip its 15 KB for readers who never get there.
  • Comments is below the fold: client:visible would skip its 45 KB for readers who never get there.
  • Footer links has nothing to click: its 8 KB is wasted.
An article page on a mid-range phone over slow 4G, HTML painted at 1.2 s. Menu button: client:load, interactive at 1.8 s; Reading progress bar: client:load, interactive at 1.9 s; Headline and article: client:load, interactive at 2.0 s; Share bar: client:load, interactive at 2.0 s; Photo carousel: client:load, interactive at 2.1 s; Newsletter signup: client:load, interactive at 2.2 s; Comments: client:load, interactive at 2.4 s; Footer links: client:load, interactive at 2.5 s. Startup JavaScript 222 KB, total 222 KB.startup 222 KB, total 222 KBMenu buttonReading progress barHeadline and articleShare barPhoto carouselNewsletter signupCommentsFooter links0s10s20s30s
Each row is one component. A dot marks when it becomes interactive; static rows ship nothing. The reader is modelled as reaching a new screenful every 6 seconds, so client:visible islands lower on the page load later, or never if the reader leaves.

Measuring what you ship

  • The Network panel, filtered to JS. Add up the scripts after load, then again after scrolling to the bottom: startup cost and worst case.
  • The Coverage panel in Chrome DevTools. It shows how much of each loaded script actually ran. Large unused percentages on a content page point at components that should not be islands.
  • Total Blocking Time in Lighthouse for the lab view, and INP from real users for the field view. Islands should lower both. If INP does not move, the slow part is probably one heavy island or a third-party script, not hydration.

Check your understanding · Exercise 7

Using the rules of thumb (3.5x unzip, about 1 MB per second on a mid-range phone), which estimates are reasonable? Select all that apply.

Failure modes, edge cases, and accessibility

Islands remove a lot of JavaScript, and with it a lot of bugs. But they add a new kind: the gap between a part of the page that looks ready and a part that is ready. Most failures below come from that gap.

Dead buttons before the island wakes

Server HTML draws a real-looking button long before its island hydrates. If the reader taps it in that window, nothing happens. This is sometimes called the uncanny valley of server rendering, and lazy directives make the window longer. A client:visible menu at the top of the page gains nothing: it is visible at once, so it starts loading at once anyway, just a little later than with client:load.

  • Notice it: a high rate of repeated taps on the same control in session recordings, rage-click metrics, or poor INP on first interactions.
  • Fix it: use client:load for above-the-fold controls; keep their islands small; and make the HTML work on its own where you can (a menu that is a link to a sitemap page, a form with a real action). Do not count on a tap made before the island woke being replayed later; test it on a throttled phone.

The forgotten directive

The opposite bug: an interactive component used without a directive renders perfectly and never works. It passes a visual review and a screenshot test. Catch it with an end-to-end test that clicks every control, or a lint rule that flags components with event handlers used without a client: directive.

Layout shift and missing content with client:only

A client:only island has no server HTML, so its space is empty until it renders. When it appears, everything below it moves: a Cumulative Layout Shift (CLS) problem. And its content is absent from the HTML that crawlers and link previews read. Reserve its space with CSS or a fallback slot of the right size, and keep content out of client:only islands.

Heavy props and hydration mismatches

Each island hydrates like a tiny SSR app, with the same mismatch risks: a timestamp in the server's time zone or a random id renders differently in the browser. Big props are the other trap: each island's props are serialized into the HTML, and a page with 30 product-card islands each carrying the full product object can ship more JSON than the old full-hydration page did.

Too many islands, or the wrong page for islands

Islands pay off when interactive parts are few and far apart. A search results page where every card has a favorite button, a hover preview, and a compare checkbox becomes 60 islands. At that point one island around the list, or a regular hydrated app, is simpler and often faster.

Navigation is the other edge. Islands sites are usually multi-page: every link is a full page load, and every island starts from scratch. A music player island stops when the reader clicks to the next article. Astro's client router with transition:persist can keep an island alive, but needing a lot of that is a sign you want a single-page app.

Accessibility angles

  • Start from semantic HTML. Because the server HTML is what users meet first, it must already be correct: real button and a elements, labels on inputs, headings in order. An island should add behavior, not fix markup.
  • State attributes must be right before hydration. A disclosure rendered with aria-expanded="false" that does not respond yet is confusing but honest. One rendered without the attribute, which the island adds later, announces differently before and after.
  • Focus must survive hydration. Hydration that reuses the DOM keeps a keyboard user's focus; a client:only island that replaces a placeholder can lose it.
  • Live regions belong in the server HTML. A cart badge that announces changes needs its aria-live region present from the start; a region inserted at hydration time may not be announced.

Check your understanding · Exercise 8

A news site moved to islands. Field data shows readers tapping the header menu, a client:idle island, two or three times before it opens. Which fixes address the cause? Select all that apply.

In real products, and in the interview

Who uses which approach

  • Astro is islands-first: zero JavaScript by default, with islands in React, Preact, Svelte, Vue, or Solid. It is popular for docs (its Starlight theme), blogs, and marketing sites.
  • Fresh, Deno's framework, ships only the Preact components in its islands folder.
  • Eleventy offers is-land, a small custom element that wakes its children on triggers like visible, idle, or a media query, around any framework or plain web components.
  • Marko, the framework eBay built and uses for its pages, has done automatic partial hydration for years: its compiler works out which components are interactive, so authors do not mark islands by hand.
  • Qwik, built at Builder.io, is the main resumable framework, with Qwik City for routing.
  • React Server Components (Next.js App Router and others) bring partial hydration into a single React tree. The react-server-components lesson covers them.

Where you do not see islands: Gmail, Figma, Notion, trading dashboards. Almost every pixel is interactive and the user stays for hours, so the startup cost is paid once and amortized. The choosing-rendering lesson walks through those cases.

A decision flowchart. First question: is the page mostly static content? If no, use a hydrated app, with selective hydration and Server Components. If yes, next question: are the interactive parts few and separate? If no, use one bigger island or Server Components. If yes, use islands, with a trigger per island. Then: is startup JavaScript still too big? If yes, consider resumability with Qwik, if the team can adopt it.Mostly static content?noHydrated app+ selective, RSCyesFew, separate widgets?noOne big islandor RSCyesIslandsa trigger per islandStartup JS still too big?yesResumabilityQwik, if team can
Most content pages stop at islands. Resumability is the last step, for when startup JavaScript is still the bottleneck and a new framework is acceptable.

When to bring it up

Bring islands up when the prompt is content-heavy and reached from search or links: a news site, a blog platform, docs, a product detail page, a landing page. Those prompts usually list SEO and fast first load on mobile as non-functional requirements. That is your cue: "most of this page is static, so I would ship it as HTML and hydrate only the interactive parts".

Do not force it into an app prompt. For a chat app, a spreadsheet, or a dashboard, say why islands do not fit: almost everything is interactive and shares state, so the savings are small and the glue code is large.

What to draw and what to say

  1. Draw the page and shade the islands, each labelled with its trigger.
  2. Give a number. "Full hydration would ship around 400 KB and keep a mid-range phone busy for about 1.5 s. Islands put about 90 KB on the critical path, about 0.3 s."
  3. Name the cost. Shared state across islands (cart badge and add-to-cart via a tiny store plus a cookie), full page navigations, and the dead-button window for lazy islands.
  4. Mention the alternatives. Selective hydration for app-like React pages, Server Components for partial hydration in one tree, resumability when startup JavaScript is still the bottleneck.

How deep to go by level

LevelWhat a strong answer shows
JuniorKnows hydration ships code for static parts; can say "only hydrate the interactive parts" and name Astro.
MidPicks a directive per component with reasons; explains partial versus progressive hydration; estimates bytes.
SeniorDesigns shared state between islands, handles the dead-button window and navigation, compares islands, RSC, and resumability, and says when not to use islands.
StaffSets a JavaScript budget per page type, adds lint and CI checks (bundle size, forgotten directives), and plans a gradual migration route by route.

Check your understanding · Exercise 9

Which prompts are good fits for an islands architecture? Select all that apply.

Summary

  • Classic hydration re-runs every component in the browser, so a mostly static page pays to download and run code for text that never changes.
  • Islands render the page as static HTML and hydrate only the interactive components, each as its own root, with its own bundle and its own trigger.
  • Choose a trigger per island: client:load for early, above-the-fold controls; client:idle for soon-but-not-first; client:visible for below the fold; client:media for one screen size; client:only for browser-only widgets, never for content.
  • Partial hydration reduces how much runs; progressive and selective hydration only change when it runs. Only the first lowers the total cost.
  • Resumability (Qwik) skips the replay by writing listeners and state into the HTML and loading each handler on demand; the costs are serialization rules and a possible first-interaction delay without prefetching.
  • Islands share no tree, so shared state needs a tiny store, events, or a cookie, and each extra framework adds a runtime.
  • Price it with numbers: 3.5x unzip and about 1 s per MB on a mid-range phone turns 420 KB of full hydration into about 1.5 s, and 87 KB of islands into about 0.3 s.
  • Islands fit content pages; app-like pages that are interactive everywhere gain little and are better served by a hydrated app or Server Components.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What is the islands architecture, and why does it help content pages?

Question 2

How is partial hydration different from progressive or selective hydration?

Question 3

Explain resumability. What does Qwik avoid, and what does it pay instead?

Question 4

Two islands, an add-to-cart button and a header cart badge, must stay in sync. How?

Question 5

Which client directive would you give a header menu, a share bar, a comments section, and a mobile-only filter drawer? Why?

Question 6

When would you not use islands?

Practice it

These design problems lean on islands and partial hydration.

References

Read next