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.

Main-thread cost of eager loading versus per-island triggers Eager imports execute every definition before the page is interactive, while island triggers load only the visible component and defer the rest until they are needed. What executes before the reader can use the page eager banner chart 180 kB editor 240 kB map 310 kB islands banner not loaded until visible, interacted with, or idle eager cost 734 kB parsed and compiled before the banner upgrades paid by every reader whether they scroll or not island cost 4 kB for the visible component the rest on demand markup is complete meanwhile so nothing is missing, only inert

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.

Debugging Pitfall — the island that loads on scroll but not on focus. A visibility trigger alone misses keyboard users entirely: tabbing to a control inside a not-yet-visible island moves focus into an inert element, and nothing loads because the region never intersected the viewport. Every island needs at least two triggers — one spatial, one interaction-based — and 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.

Hydration triggers and what each one covers Visibility covers scrolling readers, interaction covers pointer and keyboard entry, idle covers everything eventually, and media covers viewport-conditional components. Combine triggers: no single one covers every reader visible IntersectionObserver rootMargin preloads just before entry covers scrolling readers misses keyboard entry into off-screen regions interaction pointerenter, focusin click as a last resort capture phase covers keyboard and pointer first interaction may be lost if not replayed idle requestIdleCallback with a timeout after first paint a safety net for everything still costs the bytes eventually media matchMedia query never loads where it is not needed desktop-only widgets pair with another trigger, never alone

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.

The three states an island passes through, and what the reader sees in each Server-rendered content is readable immediately, the loading state keeps that content in place while the definition arrives, and the hydrated state replaces behaviour without shifting layout. 1. server-rendered real table, real links shadow root from markup readable, no script 2. trigger fired definition importing :not(:defined) still styled nothing disappears 3. hydrated element upgraded behaviour attached space was reserved: no shift If state one is an empty element, the whole technique degrades to a delayed blank box — server-render the content, not a placeholder.

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.