Vanilla Islands and Custom Loaders

Every islands framework is, underneath, three small things: a marker emitted by the server, a map from marker to module, and a scheduler that decides when to fetch. You can write all three in about a hundred lines, and for a server-rendered application β€” Django, Rails, Laravel, Go templates, or a static site generator β€” that is often a better fit than adopting a component framework to gain a pattern you can implement directly. This guide builds the loader, then examines honestly where the hand-rolled version starts costing more than it saves, using the same vocabulary as the rest of framework-specific islands and streaming SSR.

Three Parts, and What Each One Owns The server template owns the marker: a component name, a serialised props payload and a trigger. The build owns the module map: component names resolved to hashed module URLs with integrity metadata. The client owns the scheduler: it finds markers, arms triggers, imports modules and mounts them. A note explains that the three are decoupled and can be replaced independently. Marker Β· map Β· scheduler SERVER TEMPLATE data-island="search" data-trigger="visible" adjacent JSON props script any templating language will do β€” no component framework required BUILD-TIME MAP "search" β†’ /js/search.a91f.js "cart" β†’ /js/cart.7c02.js emitted as JSON by the bundler content hashes give immutable caching for every module CLIENT SCHEDULER query markers once arm observer or idle callback import, parse props, mount about 60 lines, ~1 KB shipped The three parts are decoupled: you can change templating language, bundler or scheduler independently of the other two.

Concept Definition & Scope

A vanilla island is a region of server-rendered HTML with a machine-readable claim attached: this element becomes interactive, here is the code that does it, here is when to run it, and here are the values it starts from. Nothing about that claim requires a component framework β€” it requires a convention and something that reads it.

What you give up is the part frameworks are genuinely good at: producing the same markup on the server and the client from one source. In a hand-rolled setup the server template and the client component are separate implementations of the same UI, so they drift. The standard mitigation is to make the client code operate on the existing DOM rather than re-render it β€” attaching listeners, toggling classes, patching text nodes β€” which is both cheaper and immune to mismatch, at the cost of being awkward for genuinely dynamic interfaces.

In scope: markers, the module map, the scheduler, and the operational concerns of running this yourself. Out of scope: choosing between this and a framework, which depends on how dynamic your interfaces really are β€” a question when to use islands versus full hydration treats in detail.

Technical Mechanics

Here is the whole loader. It is deliberately small enough to read in one sitting and to audit before shipping.

// /js/islands.js β€” the entire scheduler. Loaded with defer; ~1 KB minified.
const MAP_URL = '/js/island-map.json';        // built by your bundler
let mapPromise = null;                        // fetched once, lazily

const loadMap = () => (mapPromise ??= fetch(MAP_URL).then((r) => r.json()));

// Read props from a sibling <script type="application/json"> if present.
// A script tag avoids attribute escaping and keeps view-source readable.
function readProps(el) {
  const node = el.querySelector(':scope > script[type="application/json"]');
  try { return node ? JSON.parse(node.textContent) : {}; }
  catch { return {}; }                          // malformed props must not kill the page
}

async function activate(el) {
  if (el.dataset.islandState) return;           // idempotent: never mount twice
  el.dataset.islandState = 'loading';
  const name = el.dataset.island;
  performance.mark(`island:start:${name}`);

  const map = await loadMap();
  const url = map[name];
  if (!url) { el.dataset.islandState = 'error'; return; }

  const mod = await import(/* @vite-ignore */ url);
  // mount(element, props) attaches behaviour to EXISTING markup β€” it must not
  // replace innerHTML, or the server render was wasted and layout will shift.
  mod.mount(el, readProps(el));

  el.dataset.islandState = 'ready';
  performance.mark(`island:hydrated:${name}`);
}

function schedule(el) {
  switch (el.dataset.trigger) {
    case 'load':                                // above the fold, needed at once
      activate(el);
      break;
    case 'idle':                                // cheap, non-critical widgets
      ('requestIdleCallback' in window
        ? requestIdleCallback : setTimeout)(() => activate(el), 1);
      break;
    default: {                                  // 'visible' β€” the sane default
      const io = new IntersectionObserver((entries) => {
        for (const e of entries) {
          if (!e.isIntersecting) continue;
          io.disconnect();
          activate(e.target);
        }
      }, { rootMargin: '200px' });              // start early enough to finish in time
      io.observe(el);
    }
  }
}

// Run on DOMContentLoaded, then again for markup that streamed in afterwards.
const boot = () => document.querySelectorAll('[data-island]').forEach(schedule);
document.addEventListener('DOMContentLoaded', boot);
new MutationObserver(boot).observe(document.body, { childList: true, subtree: true });

The MutationObserver at the end is the part most hand-rolled loaders forget. Markup that arrives after the initial parse β€” a streamed fragment, a fragment swapped in by a partial-update library β€” contains markers that the single startup query never saw, so those islands never activate. The symptom is an interface that works on a fast connection and has dead regions on a slow one.

Comparison: Hand-Rolled Against a Framework

Dimension Vanilla loader Islands framework
Runtime shipped ~1 KB, yours 1–40 KB depending on the framework
Server and client markup Two implementations, can drift One component, guaranteed to match
Chunk splitting Whatever your bundler config says Automatic, per component
Streaming-aware activation You add a MutationObserver Built in
Prop serialisation You write it, including the escaping Provided and tested
Team onboarding Read one file Read the framework’s docs
Upgrade cost You own every bug Someone else fixes some of them

The row that decides most projects is the second. If your interactive regions are behavioural β€” toggles, filters that submit, menus, validation β€” the two-implementation problem barely exists, because the client code never re-renders the markup. If your regions are genuinely dynamic, rendering lists that change shape, you will end up wanting a component model, and writing one badly is the expensive path.

Where the Hand-Rolled Version Stops Paying A spectrum from behavioural to generative interactivity. On the left, regions that only attach behaviour to existing markup β€” toggles, menus, filter forms, validation β€” suit a vanilla loader. In the middle, regions that patch text and attributes are workable with care. On the right, regions that render lists whose shape changes need a component model, and hand-rolling one is more expensive than adopting a framework. The deciding question is what the client code does to the DOM attaches behaviour menus, disclosure, tabs filter forms, validation copy buttons, dialogs vanilla loader is ideal patches values counters, totals, badges optimistic toggles live availability workable, with discipline generates markup lists that reorder nested editable trees canvas-backed editors use a framework Most server-rendered applications are two-thirds left-hand column, which is why the hand-rolled loader keeps reappearing.

Step-by-Step Integration Pattern

  1. Define the marker contract and write it down. Element attribute for the name, an optional trigger attribute, and a JSON script child for props. Put the contract in the repository as a short document; every template author and every module author depends on it.

  2. Generate the module map from the bundler. Each island entry point emits a hashed file; the build writes a name β†’ url JSON map. Never hand-maintain this map β€” a stale entry produces an island that silently fails in production and works locally.

  3. Ship the loader with defer, not async. Order matters: defer guarantees the document is parsed before the loader runs, which means the startup query sees every marker present in the initial HTML.

  4. Give each module a mount(element, props) export. One convention, enforced by review. The function must be idempotent and must not replace the element’s markup.

  5. Add the delegated capture-and-replay listener. A click on an island that has not activated must not be lost. The design is described in full in event delegation in partially hydrated apps; the vanilla implementation is about twenty lines.

  6. Instrument from the first day. The performance marks in the loader above cost nothing and turn every production trace into an activation timeline. Without them, you will be guessing about a system you wrote yourself.

When to Stop Hand-Rolling

There is a recognisable point at which the loader stops being an asset. Three signals mark it, and noticing them early saves a rewrite later.

The first is a growing list of special cases inside the scheduler β€” a per-island retry policy here, a priority queue there, a hack for one component that must activate before another. Each addition is reasonable and the sum is a framework you did not intend to write and cannot hand to a new team member with a link to documentation.

The second is client-side rendering creeping into mount functions. The moment a mount starts producing markup rather than attaching to it, you have taken on reconciliation, keying and list diffing β€” the exact problems component frameworks exist to solve, now solved by you, in one repository, without tests.

The third is duplicated templates. When the same list markup exists once in a server template and once in a client module, every change touches both and they drift. If you find yourself extracting a shared template language to fix that, you are building a component framework in earnest.

None of these means the pattern was wrong; the marker-plus-scheduler design remains correct, and every framework in framework-specific islands and streaming SSR implements it. It means the implementation should be someone else’s. Migrating is usually straightforward precisely because the boundaries are already drawn β€” the markers become framework components, the map becomes the framework’s manifest, and the scheduler is deleted.

Measurement & Validation

Verify no island activates twice. The islandState guard exists because the MutationObserver re-runs the boot function; a mount that is not idempotent will attach duplicate listeners and fire every handler twice. Test it by mutating the DOM after load and asserting one mount mark per island.

Verify late markup activates. Insert an island marker after DOMContentLoaded from the console and confirm the observer picks it up. This is the check that catches the streaming gap before a slow connection does.

Verify the map is fetched once. The ??= cache in loadMap means one request regardless of island count. A network log showing one map fetch per island means the cache was lost in a refactor β€” an easy regression with a visible cost on pages with many islands.

Budget the loader itself. It is code on the critical path of every page. Keep it under a couple of kilobytes and re-measure when someone adds a feature to it; a loader that grows to handle five special cases has become a framework with no test suite.

Failure Modes

1. innerHTML in the mount function. The single most damaging mistake: the server render is discarded, the layout shifts, and any focus or selection the visitor had is destroyed. Fix: mount functions attach and patch, never replace. Enforce with a review rule and a lint check for innerHTML inside island modules.

2. Props parsed before they exist. With streamed markup, the element can be present before its JSON script child has arrived. The loader reads empty props and the island initialises with defaults. Fix: for streamed regions, place the props script before the marker element in source order, or wait for the element’s data-props-ready attribute set by the server after the payload is written.

3. A stale module map served from cache. The map is fetched at runtime, so if it is served with a long cache lifetime it will point at modules a deploy has deleted. Fix: serve the map with a short lifetime or no cache, and the modules themselves as immutable β€” hashes make that safe.

The Loader Budget A hand-rolled loader is worth having while it stays under roughly a hundred lines and a kilobyte. The chart shows the three additions that typically double it β€” a priority queue, per-island retry policies and a debugging escape hatch β€” and marks the point at which adopting a framework costs less than maintaining the result. Size is the signal to watch baseline loader ~60 lines Β· under 1 KB Β· one job + priority queue still defensible + retries, timeouts, escape hatch adopt a framework Measure the loader in the same budget as the islands it loads β€” it is on the critical path of every page.

Frequently Asked Questions

Is a hand-rolled island loader a reasonable production choice?

For a server-rendered application with a handful of interactive regions, yes β€” the loader is under a hundred lines and it removes an entire framework from the critical path. It stops being reasonable when you need things frameworks give you for free: server-rendered component markup that matches the client render exactly, streaming-aware activation, and a build that splits chunks per component automatically. The honest test is whether you are re-implementing those, because at that point you are maintaining a framework without a community.

How should props reach a vanilla island?

Through a JSON script tag adjacent to the marker rather than an attribute, once the payload is bigger than a couple of scalars. Attributes require HTML escaping, become unreadable in view-source, and have practical length limits in some proxies. A script tag of type application/json escapes only the closing-tag sequence, keeps the markup legible, and parses in one call at activation time.

Do I need web components for this?

No, and using them changes the trade-offs rather than simplifying them. A custom element gives you a lifecycle callback that fires when the element is parsed, which is a natural activation hook and works with streamed markup arriving late. It also brings upgrade timing, shadow DOM styling questions and event retargeting. A plain element plus a scheduler is simpler to reason about; a custom element is better when markup arrives progressively and you want activation without a central registry.

How do vanilla islands handle clicks that arrive before activation?

Exactly as a framework does, because the problem is in the DOM rather than in the framework: one delegated listener at the container captures early events, records them in a bounded queue keyed by island, and replays them once the module has mounted. Without that queue the first interactions on a slow connection are silently dropped, which is the most common defect in hand-rolled implementations.

← Back to Framework-Specific Islands & Streaming SSR