Reducing JavaScript Payload in Islands Apps
Islands Architecture promises that a page ships only the JavaScript its interactive zones genuinely need. In practice, teams often migrate to islands and discover the total payload barely moved — because each island still drags in a heavy runtime, or because the bundler collapsed every island into one shared chunk that every page downloads. The byte count that matters is not the size of your framework; it is the sum of JavaScript the browser must parse, compile, and execute before a given island becomes interactive. This guide covers the concrete levers that move that number down: tree-shaking, per-island code-splitting, deferred hydration, and dependency budgeting — plus how to measure the result so a regression never ships silently. It sits within islands performance optimization, where payload is one of three levers alongside interactivity metrics and framework selection.
Concept Definition & Scope
“Payload” in an islands app is not a single number. It decomposes into three quantities that behave differently and are optimised by different techniques:
- Total shipped bytes — everything a page could download. Reduced by tree-shaking and dependency substitution.
- Critical-path bytes — the JavaScript that must be fetched, parsed, and executed before the highest-priority island becomes interactive. Reduced by code-splitting and by moving lower-priority islands off the load-triggered path.
- Executed bytes — the code the main thread actually runs during the load window. Reduced by deferred and lazy hydration, which is the timing dimension of the same problem.
The distinction matters because a technique can improve one and regress another. Aggressive code-splitting lowers critical-path bytes but, done carelessly, raises total bytes by duplicating shared dependencies across chunks. The goal is to move the executed bytes as low as possible for the initial interaction, which is what partial hydration exists to enable: it scopes execution to one island boundary at a time so the browser never has to run the whole page’s JavaScript at once. Scope here is deliberately narrow — this page is about the JavaScript that ships with and hydrates islands, not the server rendering pipeline that produces the shell.
Technical Mechanics
The single most important structural fact is that an island is a bundler entry point. Whatever an island imports, transitively, ends up in its chunk. That is the lever and the trap. Consider a search island that imports an entire utility library at the top level:
---
// SearchBox.astro — the island's server frame. No JS ships from here.
---
<input type="search" aria-label="Search products" />
<script>
// ❌ Namespace import: the bundler cannot prove which members are used,
// so tree-shaking is defeated and the whole library lands in search.js.
import _ from 'lodash';
const debounced = _.debounce(runQuery, 200);
</script>
The fix is to import only the function you use, from a package the bundler can statically analyse:
<script>
// ✅ Named import from a side-effect-free ESM entry point.
// Only debounce and its internal deps are included; the rest is dropped.
import debounce from 'lodash-es/debounce';
const debounced = debounce(runQuery, 200);
</script>
Tree-shaking depends on three conditions holding simultaneously: the dependency ships ESM (not CommonJS), it declares "sideEffects": false (or a precise file list) in its package.json, and you import named members rather than a namespace. Break any one and the bundler conservatively keeps everything. For a genuinely heavy dependency that only one interaction needs — a charting library, a rich-text editor, a QR encoder — the right move is not to shrink it but to defer it behind the interaction that requires it:
<script>
// $state holds the lazily-loaded module; it is null until first open.
// The heavy charting dependency is NOT in this island's initial chunk —
// the dynamic import() creates a separate chunk fetched only on demand.
let chart = $state(null);
async function open() {
// The await boundary is where the code-split happens.
const { renderChart } = await import('./heavy-charting.js');
chart = renderChart;
}
</script>
<button onclick={open}>Show analytics</button>
{#if chart}<div use:chart></div>{/if}
The island’s initial chunk now contains only the button wiring; the charting code is a distinct chunk that never touches the critical path unless the user clicks. This is the same mechanism code-splitting islands by route and interaction generalises across a whole app.
Technique Comparison
Each lever targets a different quantity and carries a different cost. Choose by which number your measurements say is the problem.
| Technique | Primary target | Typical reduction | Cost / risk | Best when |
|---|---|---|---|---|
| Tree-shaking | Total shipped bytes | 20–60% of a dependency | Requires ESM + sideEffects:false; silent failure if broken |
A dependency is large but you use a fraction of it |
| Per-island code-splitting | Critical-path bytes | 40–80% of load JS | Can duplicate shared deps if chunks not hoisted | Multiple islands with distinct dependency sets |
| Lazy / deferred hydration | Executed bytes (TBT) | 50–90% of load-time execution | First-interaction latency on the deferred island | Below-the-fold or interaction-gated islands |
| Dependency substitution | Total shipped bytes | Whole packages | Behavioural differences between libraries | A lighter equivalent exists (e.g. date-fns vs moment) |
| Dependency budgeting | Regression prevention | Holds the line | Build fails on overage — needs a triage process | Any team shipping islands continuously |
| Shared vendor chunk | Duplicate bytes | Deduplicates common deps | Extra request; over-splitting hurts caching | ≥ 2 islands share a runtime dependency |
The rows are complementary, not alternatives. A mature setup uses all of them: substitute the heavy library, tree-shake what remains, split it per island, extract the shared runtime into a vendor chunk, defer the non-critical islands, and gate the whole thing with a budget.
Step-by-Step Integration
The following sequence takes a Vite-based islands project (Astro, SolidStart, or a custom setup) from an undifferentiated bundle to budgeted per-island chunks.
-
Assign a budget per island. Decide a compressed transfer ceiling for each island class — for example 15KB for a leaf control, 40KB for a data grid. Write these into a manifest your CI can read.
-
Give each island its own entry. Ensure every island is reached through a dynamic
import()or a framework client directive, not a static import from a shared bootstrap. Static imports collapse islands back into one chunk. -
Extract shared runtime into a vendor chunk. Configure the bundler so dependencies imported by two or more islands are hoisted once:
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
// Route framework runtime and shared deps into a single cached chunk
// instead of duplicating them into every island chunk.
manualChunks(id) {
if (id.includes('node_modules/@preact') || id.includes('node_modules/preact')) {
return 'vendor-runtime';
}
},
},
},
},
};
-
Convert top-level heavy imports to interaction-gated dynamic imports. Any dependency only one handler needs moves behind an
await import()at the event boundary, as in the chart example above. -
Apply hydration priority. Tag each island with the directive that matches its urgency —
client:loadonly for above-the-fold interactivity,client:idlefor deferrable,client:visiblefor below-the-fold. This is a payload move because deferred chunks leave the critical path, and it is picked up by streaming SSR when the shell itself is flushed incrementally. -
Measure, then gate. Analyse the emitted chunks and wire the budget into CI, as the next section describes.
Measurement & Validation
You cannot optimise payload you have not attributed to a specific island. Two measurements together give the full picture: a static analysis of what the bundler emitted, and a runtime measurement of what actually executed.
For the static view, generate a treemap of the build output and attribute every byte to a chunk. The full workflow — configuring the visualiser, reading the treemap, and mapping chunks back to islands — is covered in how to analyze island bundle size with rollup-plugin-visualizer. The key output is a per-chunk compressed size you can assert against your budget:
// scripts/check-budget.mjs — fail CI when an island chunk exceeds its budget.
import { readFileSync, readdirSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
const budgets = { 'search': 15_000, 'cart': 15_000, 'grid': 40_000 }; // bytes gz
const dir = 'dist/_astro';
let failed = false;
for (const file of readdirSync(dir)) {
const island = Object.keys(budgets).find((k) => file.startsWith(k));
if (!island) continue;
const gz = gzipSync(readFileSync(`${dir}/${file}`)).length;
if (gz > budgets[island]) {
console.error(`✗ ${file}: ${gz}B > budget ${budgets[island]}B`);
failed = true;
} else {
console.log(`✓ ${file}: ${gz}B (budget ${budgets[island]}B)`);
}
}
process.exit(failed ? 1 : 0);
For the runtime view, confirm that deferred islands are genuinely off the critical path. Record a Performance trace and check that the deferred island’s chunk request begins after the load-priority island is interactive. A PerformanceObserver on resource entries makes this assertable:
// Assert that cart.js is fetched during idle, not in the load burst.
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.name.includes('cart')) {
// startTime should sit well after the first interactive island's chunk.
console.log(`cart chunk fetched at ${e.startTime.toFixed(0)}ms`);
}
}
}).observe({ type: 'resource', buffered: true });
Cross-reference these numbers with the interactivity metrics from measuring hydration and interactivity metrics — a payload reduction is only real if TBT and INP move with it.
Failure Modes
1. Duplicated shared dependencies. Two islands each import the same 20KB library, and because neither is a common ancestor the bundler emits it into both chunks. Total bytes go up after splitting. The treemap shows the same package name inside two island chunks. Fix: hoist it with manualChunks, or route both islands through a shared module the bundler recognises as a common owner.
// Force the shared library into one chunk so it is downloaded and cached once.
manualChunks(id) {
if (id.includes('node_modules/date-fns')) return 'vendor-date';
}
2. Broken tree-shaking from a CommonJS or side-effectful dependency. You import one function but the whole package ships because it is CJS or lacks "sideEffects": false. Symptom: a named import produces a chunk far larger than the function warrants. Fix: switch to the package’s ESM build (often a -es sibling), or substitute a modular equivalent. Verify by re-running the treemap and confirming only the used export survives.
3. Over-splitting into request floods. Every trivial control becomes its own chunk, so a page fires twenty tiny requests, each with connection and parse overhead, and HTTP caching fragments. Symptom: TBT drops but the network waterfall is a picket fence of 2KB files and total load time worsens. Fix: consolidate adjacent low-complexity controls into one island; reserve separate chunks for islands with genuine, distinct dependency weight — the same over-fragmentation trap that undermines partial hydration.
Related
- How to Analyze Island Bundle Size with rollup-plugin-visualizer — the treemap workflow that attributes every byte to a specific island chunk.
- Code-Splitting Islands by Route and Interaction — dynamic-import boundaries that keep non-critical islands off the load path.
- Measuring Hydration and Interactivity Metrics — confirm a payload win actually moves TBT and INP.
- Islands Performance Benchmarks by Framework — how each framework’s default bundling affects shipped bytes.
- Framework-Specific Islands & Streaming SSR — how streaming the shell interacts with deferred island chunks.
← Back to Islands Performance Optimization