Deferring Hydration with Islands: Loading Component Definitions Only When They Matter
A server-rendered page with forty custom elements on it does not need forty component definitions before it is useful. Most of those elements are below the fold, several are never interacted with, and a few — a chart, a code editor, a map — carry more JavaScript than the rest of the page combined. Loading all of them eagerly makes the browser parse and execute code for components the reader may never reach, which is the largest avoidable cost in a component-heavy page.
Islands architecture inverts that. Each server-rendered component is a self-contained region whose markup is complete without script, and whose definition is fetched on a trigger: visibility, interaction, idle time, or a media query. This deep dive belongs to Server-Side Rendering & Hydration inside Distribution, Testing & Tooling.
Problem Statement: Every Definition Loaded, Most of Them Unused
<!-- Server-rendered page. Every definition is imported before anything is interactive. -->
<script type="module">
import '@wfc/components/banner.js';
import '@wfc/components/chart.js'; // 180 kB, one instance, below the fold
import '@wfc/components/editor.js'; // 240 kB, opened by 3% of readers
import '@wfc/components/map.js'; // 310 kB, behind a tab nobody clicks
import '@wfc/components/table.js';
</script>
<wfc-banner tone="info">Build passing</wfc-banner>
<article>…3000 words…</article>
<wfc-chart data-series="cpu"></wfc-chart>
<wfc-editor language="yaml"></wfc-editor>
The banner needs 4 kB and is visible immediately. The chart, editor, and map need 730 kB between them and are not visible at all. The browser downloads, parses, and compiles every byte before the module finishes, so the banner’s own upgrade — and every interaction on the page — waits behind components the reader has not scrolled to.
The naive fix, marking the imports async, changes nothing important: the requests still start immediately and still compete for bandwidth and main-thread time with the content the reader is actually looking at.
Root-Cause Analysis
Custom elements make deferred loading unusually safe, for a reason worth stating precisely: an element that has not been upgraded is still in the DOM, still styled, and still readable. The HTML Standard defines upgrade as a transition an existing element undergoes when its definition is registered — customElements.define walks the document and upgrades every matching element already present. So markup rendered by the server is complete before any definition arrives, and arrives late rather than never.
Combine that with Declarative Shadow DOM and the pre-upgrade state is not merely readable but fully styled: the parser attaches the shadow root from <template shadowrootmode>, the component’s own CSS applies, and the only thing missing is behaviour.
Three platform pieces make the deferral practical.
customElements.whenDefined(tag) returns a promise resolving when the definition lands, so code can await a specific component without polling.
The :defined pseudo-class distinguishes upgraded from not-yet-upgraded elements in CSS, which is what lets a component show a skeleton until its definition arrives — the technique in graceful degradation without JavaScript.
Dynamic import() returns a promise for a module, so a trigger handler can fetch a definition at the moment it becomes worth having.
focusin is the one most often forgotten. The bug is invisible to mouse testing and is a genuine accessibility failure, not a performance nicety.
There is also a correctness constraint on the markup. Because the island’s content must be usable before hydration, the server has to render the meaningful content, not a placeholder. An island whose server output is <wfc-chart></wfc-chart> degrades to nothing; one whose output includes a <table> of the same data degrades to a readable table. This is what separates islands from lazy loading: the deferred thing is behaviour, not content.
Production-Safe Pattern
One small loader element wraps each island, declares its triggers in markup, and imports the definition once.
/** Loads a component definition on the first trigger that fires. */
class WfcIsland extends HTMLElement {
#controller = null;
#loaded = false;
connectedCallback() {
// Already defined (another island loaded it): nothing to do.
const tag = this.getAttribute('for');
if (tag && customElements.get(tag)) return;
this.#controller = new AbortController();
const { signal } = this.#controller;
const on = (this.getAttribute('on') ?? 'visible interaction idle').split(/\s+/);
if (on.includes('visible') && 'IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
this.#load();
}
}, { rootMargin: '200px' }); // start slightly before entry
observer.observe(this);
signal.addEventListener('abort', () => observer.disconnect(), { once: true });
}
if (on.includes('interaction')) {
// focusin covers keyboard entry, which a visibility trigger cannot.
for (const type of ['pointerenter', 'focusin', 'touchstart']) {
this.addEventListener(type, () => this.#load(), { signal, once: true, passive: true });
}
}
if (on.includes('idle')) {
const schedule = window.requestIdleCallback ?? ((fn) => setTimeout(fn, 1200));
schedule(() => this.#load(), { timeout: 4000 });
}
const query = this.getAttribute('media');
if (query && !window.matchMedia(query).matches) {
// Media gate: refuse to load where the component is not wanted.
this.#loaded = true;
}
}
disconnectedCallback() {
this.#controller?.abort();
this.#controller = null;
}
async #load() {
if (this.#loaded) return;
this.#loaded = true;
this.#controller?.abort(); // one trigger is enough; stop listening
const source = this.getAttribute('src');
const tag = this.getAttribute('for');
try {
await import(/* @vite-ignore */ source);
if (tag) await customElements.whenDefined(tag);
this.dispatchEvent(new CustomEvent('island-hydrated', {
detail: { tag }, bubbles: true, composed: true
}));
} catch (error) {
// A failed island must leave the server-rendered content intact.
this.#loaded = false;
this.dispatchEvent(new CustomEvent('island-error', {
detail: { tag, error }, bubbles: true, composed: true
}));
}
}
}
customElements.define('wfc-island', WfcIsland);
<!-- Server output. The island's content is COMPLETE and readable already. -->
<wfc-island for="wfc-chart" src="/components/chart.js" on="visible interaction">
<wfc-chart data-series="cpu">
<template shadowrootmode="open">
<style>:host { display: block; } table { width: 100%; }</style>
<slot></slot>
</template>
<table>
<caption>CPU utilisation, last 24 hours</caption>
<tr><th>00:00</th><td>31%</td></tr>
<tr><th>06:00</th><td>44%</td></tr>
<tr><th>12:00</th><td>72%</td></tr>
</table>
</wfc-chart>
</wfc-island>
<wfc-island for="wfc-map" src="/components/map.js" on="interaction" media="(min-width: 900px)">
<wfc-map>
<a href="/locations">View all 12 locations as a list</a>
</wfc-map>
</wfc-island>
/* Pre-upgrade appearance, so the island never looks broken while it waits. */
wfc-chart:not(:defined) table { display: table; }
wfc-chart:defined table { display: none; } /* the chart replaces it */
wfc-island:not(:has(:defined)) { min-block-size: 12rem; } /* reserve space, no shift */
Four decisions carry this. Triggers are declared in markup, so the server decides the policy per component rather than the bundle deciding globally. focusin sits alongside the visibility observer, so keyboard users are covered. The island’s content is the fallback — a real table, a real link — so a failed or slow load degrades to something useful rather than to nothing. And the loader reserves space so hydration does not shift layout, which is the cheapest way to keep the deferral invisible to the reader.
Verification
const island = document.querySelector('wfc-island[for="wfc-chart"]');
// 1. Nothing is loaded before a trigger fires.
console.assert(customElements.get('wfc-chart') === undefined, 'definition not loaded yet');
// 2. The server-rendered content is already complete and readable.
const table = island.querySelector('table');
console.assert(table && table.rows.length === 3, 'content exists without JavaScript');
console.assert(getComputedStyle(table).display !== 'none', 'and it is visible pre-upgrade');
// 3. Keyboard entry hydrates, not only scrolling.
const hydrated = new Promise((resolve) =>
island.addEventListener('island-hydrated', resolve, { once: true }));
island.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await hydrated;
console.assert(customElements.get('wfc-chart'), 'focus trigger loaded the definition');
// 4. The element upgraded, and only once.
const chart = island.querySelector('wfc-chart');
console.assert(chart.matches(':defined'), 'element upgraded');
let extra = 0;
island.addEventListener('island-hydrated', () => { extra += 1; });
island.dispatchEvent(new PointerEvent('pointerenter'));
console.assert(extra === 0, 'a second trigger does not re-import');
// 5. Layout did not shift: the reserved height matched the hydrated height.
console.assert(island.getBoundingClientRect().height >= 192, 'space was reserved');
The measurement that matters in CI is the network waterfall rather than any of these assertions. Load the page in a headless browser with no scrolling and no interaction, and assert that the deferred bundles were never requested; then scroll and assert that exactly the expected one was. A regression here — someone adding a static import of the chart module to a shared entry point — is invisible to every functional test and shows up immediately in that check. Chrome’s Coverage panel gives the same answer manually: unused bytes on first load should be close to zero.
When to Use vs When to Avoid
| Component | Trigger |
|---|---|
| Above-the-fold interactive control | None — load eagerly |
| Below-the-fold chart or table | visible, with a rootMargin |
| Anything reachable by keyboard | Always include interaction |
| Heavy editor behind a tab | interaction only |
| Desktop-only widget | media plus interaction |
| Component whose content is the value | idle — hydrate eventually, degrade well meanwhile |
| Component that cannot render without script | Reconsider the design, or load eagerly |
The last row is the honest limit of the technique. Islands defer behaviour, and they only work when the server can render meaningful content. A component whose entire output comes from a client-side data fetch has nothing to degrade to, so deferring its definition simply delays an empty box — the case for server-rendering the data instead, as set out in rendering Declarative Shadow DOM on the server.
Frequently Asked Questions
What happens to elements already in the DOM when the definition arrives late?
They upgrade. customElements.define walks the document and upgrades every matching element already present, so late registration is a supported path rather than a race — the element is simply inert until then.
Is a visibility trigger enough on its own?
No. Tabbing into an off-screen island never triggers an intersection, so keyboard users reach an inert control. Pair every visibility trigger with focusin at minimum.
How do I avoid layout shift when an island hydrates?
Reserve the space in CSS before upgrade — a min-block-size on the island, or aspect-ratio on the component — so the hydrated component fills a box that already existed rather than pushing content down.
Does this work without Declarative Shadow DOM?
Yes, but the pre-upgrade appearance is unstyled light DOM rather than the component’s own design. Declarative shadow roots make the deferred state look finished instead of merely readable.
Related
- Server-Side Rendering & Hydration — the parent topic on rendering components on the server.
- Distribution, Testing & Tooling — the section this deep dive belongs to.
- Rendering Declarative Shadow DOM on the Server — producing the markup an island defers behaviour for.
- Avoiding Hydration Mismatches — what the upgrade must not disturb.
- Graceful Degradation Without JavaScript — designing the pre-upgrade state deliberately.