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.
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.
Step-by-Step Integration Pattern
-
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.
-
Generate the module map from the bundler. Each island entry point emits a hashed file; the build writes a
name β urlJSON map. Never hand-maintain this map β a stale entry produces an island that silently fails in production and works locally. -
Ship the loader with
defer, notasync. Order matters:deferguarantees the document is parsed before the loader runs, which means the startup query sees every marker present in the initial HTML. -
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. -
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.
-
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.
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.
Related
- Writing a Fifty-Line Island Loader with IntersectionObserver β the loader built up step by step with measurements at each stage.
- Hydrating Web Components as Islands β using custom-element lifecycle instead of a central scheduler.
- Event Delegation in Partially Hydrated Apps β the capture-and-replay queue this loader needs.
- Fresh and Deno Islands β the same three parts, provided by a framework.
- Reducing JavaScript Payload in Islands Apps β keeping the modules the loader fetches small.
β Back to Framework-Specific Islands & Streaming SSR