Code-Splitting Islands by Route and Interaction

The default failure of a growing islands app is that every route’s JavaScript ends up in one entry chunk, so a user landing on the home page downloads the settings modal, the checkout widget, and the admin grid they will never see. Code-splitting fixes this by making each island load on the axis that governs whether it is needed: its route and its interaction. This guide implements both — dynamic-import boundaries, per-route chunks, and interaction-triggered loading — so non-critical island code stays off the initial load path entirely. It is the structural technique behind reducing JavaScript payload in islands apps, and it depends on genuine partial hydration so a deferred island’s execution is scoped to its own boundary.

Prerequisites


Route Axis and Interaction Axis of Island Loading A diagram with two axes. The horizontal route axis shows the initial load providing only the home route's critical island. The vertical interaction axis shows a deferred island chunk loading only when the user triggers an event such as opening a modal. A chunk reaches the main thread only when both conditions are met. ROUTE AXIS → navigation INTERACTION AXIS → / home search.js loads now /checkout chunk loads on nav /admin chunk loads on nav filters modal loads on click chart export loads on deeper action A chunk reaches the main thread only when the route is active AND the interaction fires. Solid green = shipped on initial load. Dashed = deferred chunk, fetched on demand.

Implementation Steps

Step 1 — Establish a dynamic-import boundary per island

Goal: Make each island a real chunk by reaching it only through import().

A static import folds a module into the importer’s chunk. A dynamic import() tells the bundler to emit a separate chunk fetched at runtime. Route every non-critical island through the dynamic form:

// island-loader.js — a generic loader that turns any island into its own chunk.
// The /* @vite-ignore */-free template with a static base lets Rollup analyse
// and pre-generate a chunk per island rather than one catch-all bundle.
const registry = {
  filters: () => import('./islands/FiltersModal.js'),   // → filters.[hash].js
  export:  () => import('./islands/ChartExport.js'),     // → export.[hash].js
};

export async function loadIsland(name, mountNode) {
  // The await is the split point: nothing above ships this island's code.
  const { mount } = await registry[name]();
  mount(mountNode);
}

Expected output: After a build, the treemap shows filters.[hash].js and export.[hash].js as chunks distinct from the main entry.


Step 2 — Scope chunks to routes

Goal: Ensure a route’s islands are referenced only within that route, so they are not hoisted into the shared entry.

In Astro this is automatic — an island used only on /checkout.astro is emitted as a chunk loaded on that page. In a custom router, import the route’s islands lazily inside the route module:

// SolidStart-style route with a lazily-loaded island.
// clientOnly / lazy defers the island's chunk to when this route renders,
// keeping it out of the home route's initial payload.
import { lazy } from 'solid-js';

// This island chunk is fetched only when the /checkout route is entered.
const CheckoutWidget = lazy(() => import('~/islands/CheckoutWidget'));

export default function CheckoutRoute() {
  return (
    <main>
      <h1>Checkout</h1>
      <CheckoutWidget />
    </main>
  );
}

Expected output: Navigating from / to /checkout triggers a network request for the checkout chunk; loading / alone never fetches it.


Step 3 — Gate heavy islands on interaction

Goal: Defer an island present on the current route until the user actually engages it.

Load the module inside the handler that first needs it. This is the interaction axis — the island costs nothing until the click, matching the client:idle/client:visible intent from the parent guide but pushing it all the way to explicit user action:

// The export island's heavy dependency does not exist in any initial chunk.
// It is fetched the moment the user clicks "Export", and not before.
document.querySelector('#export-btn').addEventListener('click', async (e) => {
  e.currentTarget.disabled = true;                 // guard against double-load
  const { runExport } = await import('./islands/ChartExport.js');
  await runExport();
  e.currentTarget.disabled = false;
});

Expected output: The export chunk appears in the network panel only after the button click, never during initial load.


Step 4 — Preload the likely-next chunk on intent

Goal: Remove the fetch latency the user would otherwise feel on first interaction.

Warm the chunk on an intent signal that reliably precedes the action — hover or focus:

// Start the fetch on intent (pointerenter) so the chunk is warm by click.
// import() dedupes: the click's import resolves instantly against the
// in-flight or cached module. Fires at most once thanks to { once: true }.
const btn = document.querySelector('#export-btn');
btn.addEventListener('pointerenter', () => {
  import('./islands/ChartExport.js'); // fire-and-forget warm-up
}, { once: true });

Expected output: In DevTools, the export chunk starts loading on hover; the subsequent click resolves against the already-fetched module with no visible delay.


Step 5 — Compose the two axes

Goal: Combine route and interaction splitting so a chunk loads only when both conditions hold.

The two axes multiply rather than add. A route-scoped island that is also interaction-gated is fetched only if the user is on that route and triggers the interaction — the strongest possible deferral. Express it by nesting the interaction gate inside the route-scoped module, so the route chunk contains only the trigger wiring and the heavy island is a further split beneath it:

// Inside the /admin route chunk. The route chunk is already deferred to
// navigation; the grid's heavy dependency is deferred AGAIN to interaction.
export default function AdminRoute() {
  async function openGrid(node) {
    // Nested split: this dependency is not in the /admin chunk either —
    // it loads only when an admin actually opens the grid.
    const { mountGrid } = await import('~/islands/DataGrid');
    mountGrid(node);
  }
  return <button onClick={(e) => openGrid(e.currentTarget.parentElement)}>Open grid</button>;
}

Expected output: The data-grid chunk is absent from both the home load and the initial /admin navigation; it appears only when the grid is opened on the admin route.


Chunk Graph Before and After Splitting Before: three routes each ship one large bundle containing duplicated shared code. After: a single shared chunk holds the common code, each route ships a small route chunk, and each interactive widget ships its own chunk fetched on demand. Total bytes for a visitor who touches one widget fall by more than half. Before: three bundles · After: shared + route + on-demand before route A · 96 KB route B · 92 KB route C · 88 KB about 60 KB of each bundle is the same code, downloaded again on every navigation after shared · 58 KB, cached route A · 14 KB B · 12 KB C · 10 KB widget chunks · on demand first visit 72 KB, second route 12 KB, widgets only if touched Watch the shared chunk: if it grows past what a route needs, splitting has moved the problem rather than solving it.

Verification

  1. Waterfall check. Record a load trace of the home route. The deferred island chunks (filters, export, /checkout) must not appear in the initial request burst. Trigger each interaction and confirm its chunk loads only then.
  2. Entry-chunk shrinkage. Compare the main entry chunk’s compressed size before and after, using the treemap from analyzing island bundle size. The entry should drop by the sum of the islands you moved out.
  3. No duplication. Confirm no deferred island’s dependency also appears in the entry chunk. If it does, a static import somewhere is pulling it back into the main graph.
  4. TBT moves. Re-measure Total Blocking Time; deferring execution off the load path should lower it, which you confirm against measuring hydration and interactivity metrics.

Troubleshooting

The dynamic-imported island still ends up in the main entry chunk

Root cause: The same module is also statically imported somewhere — a shared type re-export, a barrel index.js, or a preload helper. When a module appears in the static graph, the bundler hoists it there and the dynamic import just references the static copy, so no split happens.

Fix: Remove every static import of the island module; reach it only through import(). Barrel files are a frequent culprit — import the island’s concrete path, not the barrel that statically re-exports it.

Interaction feels laggy the first time because the chunk fetches on click

Root cause: The chunk is fetched synchronously with the interaction, so the user waits for a network round-trip before anything happens.

Fix: Preload on an intent signal (pointerenter, focus, or an IntersectionObserver margin) so the fetch is already in flight. Add <link rel="modulepreload"> for chunks you are highly confident the user will need on the current route.

<!-- Warm a high-probability island chunk during idle, without executing it. -->
<link rel="modulepreload" href="/islands/filters.[hash].js" />
Too many tiny chunks — the network panel is a picket fence of 2KB files

Root cause: Over-splitting. Every trivial control became its own chunk, so request overhead and cache fragmentation outweigh the savings.

Fix: Consolidate adjacent low-complexity controls into a single island/chunk. Reserve separate chunks for islands with genuine dependency weight. A chunk under a few KB rarely justifies its own request; merge it into its nearest sibling island.


Two Ways Splitting Backfires Two rows. A request waterfall shows a widget chunk importing another chunk which imports a third, so activation waits for three sequential round trips instead of one. Cache churn shows a shared chunk whose content hash changes on every deploy because it includes a build timestamp, invalidating the cache for all returning visitors. Both look like wins in the bundle report chained chunk requests three sequential round trips flatten with a preload hint or fewer levels of dynamic import shared chunk hash changes every deploy a build timestamp or version string inside the chunk invalidates it for every returning visitor check by diffing two builds of the same source: the shared chunk should be byte-identical

Frequently Asked Questions

Does a dynamic import always create a separate chunk?

Almost always, but the bundler can inline a dynamic import if the target is tiny or if it is also statically imported elsewhere. A module that is both statically and dynamically imported is hoisted into the static graph, defeating the split. Make sure an island reached by dynamic import is not also statically imported by any other module.

What is the difference between route-based and interaction-based splitting?

Route-based splitting emits a chunk per route so navigating to a page loads only that page's islands. Interaction-based splitting defers a chunk until the user does something — clicks a tab, opens a modal — so an island on the current page still costs nothing until it is used. They compose: an interaction-gated island inside a route-scoped chunk is loaded only if the user is on that route and triggers the interaction.

How do I avoid a delay when the user triggers a deferred island?

Preload the chunk on an intent signal that precedes the action — pointerenter, focus, or an intersection margin — so the fetch is already in flight when the click happens. Use link rel=modulepreload or a bare import() started in the intent handler; the browser dedupes it with the real load, so the interaction resolves against a warm cache.

← Back to Reducing JavaScript Payload in Islands Apps