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.


Monolithic Bundle versus Per-Island Chunks Left column shows a single monolithic bundle that all three islands share, so every island forces the whole payload into the critical load window. Right column shows each island emitting its own small chunk plus one extracted shared vendor chunk, so only the load-priority island's bytes are critical and the rest are deferred. MONOLITHIC SHARED BUNDLE bundle.js — 210 KB framework + all 3 islands + all deps every island waits for the whole file ↓ Search blocked Cart blocked Carousel blocked Critical load window parses + executes all 210 KB before ANY island is interactive TBT ≈ 480 ms high main-thread blocking PER-ISLAND CHUNKS vendor.js shared 24 KB · cached search.js — 11 KB client:load cart.js — 9 KB client:idle · deferred carousel.js client:visible · deferred Critical load window parses vendor 24 KB + search 11 KB = 35 KB before first interactivity TBT ≈ 90 ms cart + carousel fetched off the critical path, on idle / intersection low main-thread blocking

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.

  1. 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.

  2. 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.

  3. 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';
          }
        },
      },
    },
  },
};
  1. 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.

  2. Apply hydration priority. Tag each island with the directive that matches its urgency — client:load only for above-the-fold interactivity, client:idle for deferrable, client:visible for 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.

  3. 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.

← Back to Islands Performance Optimization