Hydrating Web Components as Islands

A custom element gives you an activation hook the platform calls for you, including for markup that arrives mid-stream β€” which is exactly the case a query-once scheduler misses. That makes elements an appealing island boundary, provided the heavy work stays out of the lifecycle callback. This walkthrough uses them as boundaries within the model described in vanilla islands and custom loaders.

Prerequisites

Implementation Steps

Who Notices Markup That Arrives Late Two rows over the same streamed response. A central scheduler queries the document once at DOMContentLoaded and therefore misses a fragment that arrives afterwards unless a mutation observer is added. A custom element is upgraded by the browser whenever it is parsed, so a late fragment activates with no extra machinery. Late-arriving markup, two mechanisms central scheduler queries once at DOMContentLoaded a fragment flushed at 900 ms is invisible to it β€” needs a MutationObserver works on a fast connection, dies on a slow one custom element the browser calls connectedCallback on parse any fragment, any time, no registry and no observer the trade: one global tag name per behaviour

Step 1 β€” Server-render the working markup inside the element

<!-- The children are the island. They work before any script runs. -->
<product-filter data-endpoint="/api/products">
  <form action="/products" method="get">
    <label for="brand">Brand</label>
    <select id="brand" name="brand"><option>All</option><option>Acme</option></select>
    <button type="submit">Apply</button>
  </form>
</product-filter>

Step 2 β€” Register a stub that defers the real work

The mistake to avoid is importing the implementation in connectedCallback, which makes every element on the page eager. The stub decides when.

// islands/register.js β€” tiny, loaded on every page.
class DeferredIsland extends HTMLElement {
  connectedCallback() {
    // Already upgraded? Elements can be moved in the DOM, which re-runs this.
    if (this.dataset.islandState) return;
    this.dataset.islandState = 'pending';

    const load = async () => {
      if (this.dataset.islandState === 'ready') return;
      this.dataset.islandState = 'ready';
      const { enhance } = await import(this.constructor.moduleUrl);
      enhance(this);                       // enhances the light-DOM children
    };

    // Whichever comes first: approaching the viewport, or keyboard focus.
    this._io = new IntersectionObserver(([e]) => e.isIntersecting && load(),
      { rootMargin: '200px' });
    this._io.observe(this);
    this.addEventListener('focusin', load, { once: true });
  }

  disconnectedCallback() {
    this._io?.disconnect();                // elements are removed more often
    this._io = null;                       // than whole pages are unloaded
  }
}

class ProductFilter extends DeferredIsland {
  static moduleUrl = '/js/islands/product-filter.a91f.js';
}
customElements.define('product-filter', ProductFilter);

Step 3 β€” Enhance the light DOM rather than replacing it

// /js/islands/product-filter.js β€” the deferred implementation.
export function enhance(host) {
  const form = host.querySelector('form');
  const select = form.elements.brand;
  // Progressive: the form already submits. We intercept and fetch instead.
  form.addEventListener('submit', async (event) => {
    event.preventDefault();
    const url = `${host.dataset.endpoint}?brand=${encodeURIComponent(select.value)}`;
    const results = await fetch(url).then((r) => r.text());
    host.querySelector('[data-results]').innerHTML = results;   // only the results region
  });
}

Note what is not happening: the element never rewrites its own children wholesale, so focus, selection and scroll position inside the region survive activation.

Verification

Four Element-Specific Checks Confirm the element upgrades when inserted after load, that the implementation module is not requested until a trigger fires, that removing and re-inserting the element does not mount twice, and that styling applied to the tag name works before the definition arrives. CHECK EXPECTED insert the element after load upgrades with no extra code load without scrolling or tabbing implementation module not requested remove and re-insert the element no second mount, no duplicate listeners styles on the tag name apply before the definition loads β€” no upgrade jump

The third check catches the failure that only custom elements have. Moving an element in the DOM β€” a common operation in list reordering and in fragment swaps β€” disconnects and reconnects it, so connectedCallback runs again. Without the state guard the implementation is imported and applied twice, producing duplicated event listeners that fire every handler two times. The symptom is a form that submits twice, reported only by visitors who reordered something first.

Elements Versus a Scheduler

Both approaches implement the same three parts β€” marker, map, trigger β€” and differ in who owns the trigger. The element owns it in the platform; the scheduler owns it in your code.

Elements win when markup arrives progressively, when regions are inserted and moved by other code, and when you want zero central registry. They cost a global tag namespace, an upgrade-timing subtlety, and a slightly larger stub because each element class carries its own module URL.

A scheduler wins when you want one place to reason about priority β€” activate the search box before the carousel, cap concurrent imports, stagger activation to avoid a long task. Expressing that ordering across independent elements means coordinating them anyway, at which point you have a scheduler with extra steps.

A reasonable default is elements for regions that are inserted dynamically and a scheduler for the static page, though running both means two mechanisms to explain. If the page streams and swaps fragments regularly, elements are the simpler whole-system answer, and the guidance in streaming out-of-order HTML with Marko about late-arriving markup applies directly.

One more consideration decides the choice for many teams: who else renders into your page. If a content management system, an experimentation tool or a third-party widget injects markup you do not control, the element approach keeps working because the browser upgrades whatever appears, whenever it appears. A scheduler has to be told, and the telling is where the bug lives β€” a fragment inserted by a tool that predates your loader will simply never activate, and the report will arrive months later as β€œthe filter stopped working on the campaign pages”.

Whichever you pick, keep the naming disciplined. Custom element tag names are global to the document, so a generic name will eventually collide with a widget somebody else loads. A short prefix tied to your product removes the risk permanently and costs four characters per tag.

Naming Custom Elements Safely Tag names are global to the document, so a generic name eventually collides with a third-party widget. A short product prefix removes the risk permanently, and a consistent suffix makes island elements greppable across templates. Tag names are a global namespace product-filter Β· generic, collides eventually any widget on the page may define the same tag acme-product-filter Β· prefixed, safe four characters buy permanent isolation grep for the prefix to find every island in the templates

Troubleshooting

Do custom elements remove the need for a loader?

They remove the need for a central scheduler, because the browser calls connectedCallback for you the moment the element is parsed β€” including for markup that streams in later, which is the case a query-once loader misses. What they do not remove is the decision about when to load the heavy implementation. If connectedCallback imports the module immediately, every element on the page loads its code at parse time, which is eager activation with extra steps.

Should server-rendered content go in the shadow DOM?

Not for content that already exists in the HTML. Declarative shadow DOM makes it possible, but it complicates styling, selection and search-engine handling for no benefit when the markup was rendered by the server. Use the light DOM for content and reserve a shadow root for genuinely encapsulated widgets that generate their own markup, where style isolation is the actual goal.

What happens if the element is defined after the markup is parsed?

The element sits in the document as an unknown element until its definition arrives, and is then upgraded β€” connectedCallback runs at that point. This is usually fine, because the server-rendered children are already visible and functional. The one thing to avoid is styling that depends on the element being defined, such as a display rule applied only after upgrade, which produces a visible jump; style the tag name directly instead so the rules apply from first paint.

← Back to Vanilla Islands and Custom Loaders