Writing a Fifty-Line Island Loader
The loader is the part of an islands system people assume must be complicated. It is not: fifty lines cover marker discovery, viewport triggering and mounting, and three more additions make it production-worthy. This walkthrough builds it in that order, with the reasoning for each addition, as the concrete version of the design in vanilla islands and custom loaders.
Prerequisites
Implementation Steps
Step 1 — Emit the marker from whatever renders your HTML
<!-- Any templating language. The markup inside is a working control. -->
<div data-island="search" data-trigger="visible">
<form action="/search" method="get">
<label for="q">Search</label>
<input id="q" name="q" type="search">
<button type="submit">Go</button>
</form>
<script type="application/json">{"placeholder":"Search 4,182 products","recent":["boots","socks"]}</script>
</div>
Two properties matter. The form works without any JavaScript, so a failed or slow module costs a page navigation rather than the feature. And the props sit in a script tag rather than an attribute, so no escaping gymnastics are needed for quotes or angle brackets.
Step 2 — The naive loader, in eleven lines
const map = await fetch('/js/island-map.json').then((r) => r.json());
for (const el of document.querySelectorAll('[data-island]')) {
const mod = await import(map[el.dataset.island]);
mod.mount(el, JSON.parse(el.querySelector(':scope > script')?.textContent || '{}'));
}
This works and is wrong in three ways: it loads every island immediately, it awaits each import in sequence, and it never sees markup that arrives later. The next three steps fix exactly those.
Step 3 — Trigger on approach rather than on parse
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
io.unobserve(e.target); // one activation per element
activate(e.target); // no await here: islands load in parallel
}
}, { rootMargin: '200px' }); // start before the element is on screen
The rootMargin is the difference between an island that is ready when the visitor arrives and one that starts loading as they look at it. Pick it from the module’s real load time on a throttled connection rather than from a round number.
Step 4 — See markup that arrives after parse
const boot = () => document.querySelectorAll('[data-island]:not([data-island-state])')
.forEach(schedule);
document.addEventListener('DOMContentLoaded', boot);
new MutationObserver(boot).observe(document.body, { childList: true, subtree: true });
The :not([data-island-state]) selector plus a state attribute set on first sight makes re-running boot free, which matters because the mutation observer fires often.
Step 5 — Make it idempotent and observable
async function activate(el) {
if (el.dataset.islandState) return; // never mount twice
el.dataset.islandState = 'loading';
const name = el.dataset.island;
performance.mark(`island:start:${name}`);
try {
const mod = await import((await mapPromise)[name]);
mod.mount(el, readProps(el));
el.dataset.islandState = 'ready';
performance.mark(`island:hydrated:${name}`);
} catch (err) {
el.dataset.islandState = 'error'; // markup stays usable
reportError('island-mount-failed', { name, message: String(err) });
}
}
Verification
Run all four before the loader carries real traffic. The third is the one that pays for itself: blocking a module in the network panel simulates a bad deploy, a content blocker and a flaky connection at once, and the correct behaviour — a page that still works, one error reported, no console noise — is the reason the server-rendered baseline exists.
Beyond behaviour, watch two numbers over time. The loader’s own transferred size should stay near a kilobyte; if it creeps, someone has added a special case that belongs in an island. And the map fetch should appear exactly once per page load regardless of island count, which confirms the promise cache survived the last refactor.
Keeping It Small
Every hand-rolled loader accumulates requests: a priority queue so one island loads first, a retry with backoff, a per-island timeout, an “activate everything now” escape hatch for a debugging session. Each is reasonable and together they turn fifty lines into four hundred that nobody else on the team understands.
Two rules keep it bounded. First, anything that concerns one island belongs in that island’s module, not in the loader — a retry policy for a flaky endpoint is the component’s business. Second, anything that would need documentation to use is a signal to adopt a framework instead, because the framework’s version is documented, tested and maintained by people who are not you.
The loader has one job: decide when to import a module and hand it an element. If a proposed feature does not fit that sentence, it goes somewhere else. That discipline is what keeps the hand-rolled approach cheaper than the alternative described in vanilla islands and custom loaders, and it is also what makes the eventual migration to a framework straightforward if the project outgrows it.
Troubleshooting
Why does the loader need a MutationObserver at all?
Because markup can arrive after the initial parse. A streamed fragment, a partial update swapped in by a small library, or content inserted by another island all contain markers that the one-time startup query never saw. Without an observer those regions stay inert, and the failure is connection-dependent: everything works on a fast link where all the HTML arrives before the loader runs, and dies on a slow one where it does not.
Should the loader import modules or the whole bundle?
One module per island, resolved through a build-generated map. A single bundle defeats the purpose, because the visitor downloads code for islands they never reach. The map costs one small fetch, is cached for a short window, and lets the loader use a plain dynamic import with a hashed URL that can be cached forever.
What happens when a module fails to load?
Mark the element as failed, leave the server-rendered markup exactly as it is, and do not retry in a loop. Because the markup was rendered by the server, a failed island degrades to a static region rather than an empty box — provided the mount function was written to enhance existing markup rather than to produce it. Log the failure with the island name so a bad deploy is visible in your error reporting rather than only in visitor complaints.
Related
- Hydrating Web Components as Islands — using element lifecycle instead of a central scheduler.
- Event Delegation in Partially Hydrated Apps — capturing clicks that arrive before a module lands.
- Code-Splitting Islands by Route and Interaction — shaping the chunks this loader fetches.
← Back to Vanilla Islands and Custom Loaders