Lazy-Mounting Svelte Islands with IntersectionObserver

SvelteKit hydrates a route as a unit by default, which is the wrong granularity for a content page with a few interactive regions. A small loader gives you per-region mounts without leaving the framework, provided the components are constructed against existing markup rather than into an empty container. This extends the boundary work in SvelteKit component islands.

Prerequisites

Implementation Steps

Server Render, Observe, Hydrate The server renders the component to markup and emits its props beside it. The loader observes the container without importing anything. When the region is approached or focused, the chunk is imported and the component is constructed in hydrate mode against the existing nodes. Nothing in the region is replaced, so no shift and no state loss occur. Three stages, one of which costs nothing 1 Β· SERVER RENDER real markup, real content props in a JSON script 2 Β· OBSERVE no import, no execution costs one observer entry 3 Β· HYDRATING MOUNT import, then attach existing nodes reused Stage two is the whole optimisation A page with nine islands runs nine observers and zero component code until the visitor moves toward one.

Step 1 β€” Emit the container, the markup and the props


<div data-island="ReviewSummary" data-trigger="visible">
  
  {@html `<script type="application/json">${JSON.stringify(summaryProps)}</script>`}
</div>

Step 2 β€” The loader: observe first, import later

// src/lib/islands.js β€” one module for the whole route.
const registry = {
  ReviewSummary: () => import('$lib/islands/ReviewSummary.svelte'),
  FilterPanel:   () => import('$lib/islands/FilterPanel.svelte'),
};

const mounted = new WeakMap();

async function mount(el) {
  if (mounted.has(el)) return;                      // idempotent
  const load = registry[el.dataset.island];
  if (!load) return;
  const props = JSON.parse(el.querySelector(':scope > script')?.textContent || '{}');
  const { default: Component } = await load();
  // hydrate: true walks the existing DOM instead of replacing it β€” without
  // this the server render is discarded and the region visibly re-renders.
  mounted.set(el, new Component({ target: el, props, hydrate: true }));
}

export function initIslands(root = document) {
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (!e.isIntersecting) continue;
      io.unobserve(e.target);
      mount(e.target);
    }
  }, { rootMargin: '200px' });

  for (const el of root.querySelectorAll('[data-island]')) {
    io.observe(el);
    // Keyboard users reach regions without scrolling them into view.
    el.addEventListener('focusin', () => { io.unobserve(el); mount(el); }, { once: true });
  }
}

Step 3 β€” Tear down when the container leaves the document

new MutationObserver((records) => {
  for (const r of records) {
    for (const node of r.removedNodes) {
      if (node.nodeType !== 1) continue;
      const inst = mounted.get(node);
      if (inst) { inst.$destroy(); mounted.delete(node); }
    }
  }
}).observe(document.body, { childList: true, subtree: true });

Client-side navigation removes and re-adds large parts of the document. Without teardown, each navigation leaves the previous components alive, subscribed to stores and holding DOM references β€” a leak that only appears after several minutes of real use, which is why it rarely shows up in testing.

Verification

Four Checks Confirm no component chunk is requested until a region is approached, that mounting does not change the rendered markup, that tabbing into a region activates it, and that navigating away destroys the instance so the heap returns to its baseline. CHECK EXPECTED load without scrolling no component chunks requested diff the markup around a mount identical before and after tab into a region below the fold it activates without scrolling navigate away and back ten times Β· heap returns to baseline

The markup diff is the check that catches a missing hydrate flag. Capture the container’s innerHTML before the mount and again immediately after; they should be byte-identical apart from framework-added attributes. Any structural difference means the component re-rendered rather than attached, and the consequences β€” layout shift, lost focus, reset inputs β€” will show up for visitors long before anyone reads the diff.

For the leak check, take a heap snapshot at the start, navigate through ten routes, return, force collection and snapshot again. A retained-size increase proportional to the number of navigations points directly at missing teardown.

Where This Stops Being Worth It

Hand-rolled lazy mounting inside a framework has a natural limit, and it is worth recognising before the loader grows.

If most of the route is interactive, per-region mounting adds machinery for little benefit β€” the framework hydrating the route as a unit is simpler and, past a certain interactive share, faster because it avoids repeated observer and mount overhead. The threshold is roughly the same interactivity ratio described in when to use islands versus full hydration: below a fifth interactive, per-region wins clearly; above half, whole-route hydration wins.

If the regions need to share state continuously, the loader is not the problem but the architecture around it becomes one β€” every island subscribing to a store, seeded separately, with teardown to manage. At that point a framework-level islands mode, where the framework owns the boundaries, is less code than the equivalent hand-assembled system.

And if the route needs client-side navigation with preserved state, the loader must cooperate with the router rather than compete with it. That is achievable and it is where most of the accidental complexity in these implementations comes from, so it is worth deciding deliberately rather than discovering.

One deployment detail deserves attention because it is easy to get wrong and hard to notice. The route’s own client entry must not also hydrate the components you are mounting by hand, or every island is created twice β€” once by the framework and once by the loader β€” with duplicate listeners and doubled effects. Scope the client entry to the routes that genuinely need whole-page hydration, and confirm by counting mount marks: one per island, not two. The symptom of getting this wrong is a form that submits twice or a counter that increments by two, reported as a mysterious intermittent bug because it depends on which mount won the race.

One Mount per Island, Not Two If the route client entry still hydrates the page, every island is created twice β€” once by the framework and once by the loader β€” producing duplicate listeners and doubled effects. Count the mount marks: one per island client entry active and loader active two mounts per island; a form submits twice, intermittently client entry scoped away from island routes one mount per island, deterministic assert exactly one activation mark per island in a smoke test

Troubleshooting

Why is hydrate mode necessary rather than just mounting?

Without it the component renders its own markup and replaces whatever the server produced, which discards the server render, causes a layout shift and destroys any focus or selection inside the region. With it, the component walks the existing DOM and attaches behaviour, so the visible result of activation is that the region starts responding β€” nothing moves.

What should the root margin be?

Enough that the module has time to arrive before the region is on screen. Derive it from the module size and a realistic connection: a small island on a fast link needs almost nothing, a larger one on a slow link needs most of a viewport. Two hundred pixels is a reasonable starting point and a poor final answer; measure the gap between the intersection event and the mount mark and adjust.

Do I need to destroy components when they scroll away?

Not for scrolling β€” destroying on exit produces churn and loses state. Destroy when the container is removed from the document, which happens during client-side navigation and fragment swaps. A long session that navigates repeatedly will otherwise accumulate mounted components attached to detached DOM, which is a slow leak that only shows up after several minutes of use.

← Back to SvelteKit Component Islands