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
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.
Verification
- 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. - 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.
- 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.
- 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.
Related
- Reducing JavaScript Payload in Islands Apps — the parent guide where splitting sits alongside tree-shaking and budgeting.
- How to Analyze Island Bundle Size with rollup-plugin-visualizer — measure the entry-chunk shrinkage this splitting produces.