Astro Islands and Client Directive Asset Hashes

A client:* directive is the only thing standing between an Astro component and zero JavaScript. Remove the directive and the component renders to static HTML at build time and contributes nothing to dist/_astro/; add one and Rollup gains a browser entry point, emits a hashed chunk for that component, and pulls the framework runtime in as a shared dependency. Every question about which hashed files your Astro site ships — how many, how big, and which of them churn on a given commit — resolves to a question about directives.

What Each Directive Actually Emits

The five directives differ in when hydration happens, not in whether the component’s code is downloaded. That distinction is the single most misread part of the island model: client:visible does not avoid emitting a chunk, it only delays executing it. The chunk exists in dist/_astro/, is listed in the manifest, and is fetched by the hydration script the moment the intersection fires.

Directive Emitted into dist/_astro/ When it hydrates Hash blast radius
(none) nothing never — static HTML only zero; the component is compiled away
client:load component chunk + shared runtime on parse, before paint that component’s chunk
client:idle component chunk + shared runtime first requestIdleCallback that component’s chunk
client:visible component chunk + shared runtime on IntersectionObserver fire that component’s chunk
client:media component chunk + shared runtime when the media query matches that component’s chunk
client:only="react" component chunk + shared runtime, no SSR markup on parse, client-side only chunk plus the page HTML that no longer contains its markup

Switching a directive from client:load to client:idle leaves the component chunk’s bytes untouched — the scheduling lives in Astro’s hydration script, not in your component — so the chunk hash is unchanged. The page HTML changes, because the directive is serialised into the island’s placeholder element. In practice that means a directive-only edit invalidates one HTML document and no cached assets, which is the cheapest change you can make to an island.

The Shared Runtime Is Where the Bytes Live

Four React islands do not ship four copies of React. Rollup hoists the shared dependency graph into a common chunk that every island chunk imports, and that chunk gets its own content hash. On a typical site the runtime chunk is 40–120 KB while individual island chunks are 1–8 KB, which inverts the intuition about what matters: your first island is expensive, your fourth is nearly free.

Island chunk graph A page component with no client JavaScript of its own fans out to three islands: Counter with client:load, Chart with client:visible, and Modal with client:only. All three depend on a single shared framework runtime chunk, which in turn is written to the hashed output directory along with the three island chunks. Page.astro no client JS Counter.jsx client:load Chart.jsx client:visible Modal.jsx client:only Shared runtime one hashed chunk dist/_astro/ 4 hashed files one framework runtime is shared by every island on the site
Three islands, four hashed files: one chunk each plus the shared framework runtime they all import.

The hydration script is its own hashed file

Alongside the runtime and the island chunks, Astro emits a small client-side script that finds island placeholders in the DOM, reads their serialised props and directive, and calls the right framework’s hydrate function at the right moment. It lands in _astro/ with a content hash like everything else, and it is shared by every page on the site that has at least one island.

Because that script implements all five directives, its bytes are identical whether you use one directive or all of them — which is why switching a component from client:load to client:idle changes no hashed file. It also means the script’s hash moves only when you upgrade Astro or add a framework integration, so it behaves like vendor code for caching purposes even though it is generated by your build.

The one case that does change it is adding a new framework. A site with only React islands ships one hydration path; add a Svelte island and Astro emits an additional framework runtime chunk and a wider hydration script. That is a two-chunk change plus a re-hash of the script every page loads, so it is worth batching framework additions into a single release rather than trickling them in.

The consequence for cache stability is the same one that governs vendor versus application bundle hashing: the shared runtime changes only when the framework version changes, so it stays in the edge cache across dozens of application releases, while the small island chunks churn freely. Any configuration that accidentally merges island code into the runtime chunk destroys that property.

A Minimal Island Setup You Can Build

The following three files produce exactly the graph above. The manualChunks rule keeps node_modules code out of the per-island chunks so the runtime hash stays pinned to your dependency versions.

// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';

export default defineConfig({
  integrations: [react()],
  build: {
    assets: '_astro'
  },
  vite: {
    build: {
      rollupOptions: {
        output: {
          manualChunks(id) {
            if (id.includes('node_modules')) return 'runtime';
          },
          entryFileNames: '_astro/[name]-[hash:8].js',
          chunkFileNames: '_astro/[name]-[hash:8].js',
          assetFileNames: '_astro/[name]-[hash:8][extname]'
        }
      }
    }
  }
});
// src/components/Counter.jsx
import { useState } from 'react';

export default function Counter({ start = 0 }) {
  const [count, setCount] = useState(start);
  return (
    <button type="button" onClick={() => setCount(count + 1)}>
      Clicked {count} times
    </button>
  );
}
---
// src/pages/index.astro
import Counter from '../components/Counter.jsx';
import Chart from '../components/Chart.jsx';
import Modal from '../components/Modal.jsx';
---
<html lang="en">
  <body>
    <Counter client:load start={3} />
    <Chart client:visible />
    <Modal client:only="react" />
  </body>
</html>

Eight hex characters is the right default for the hash segment; monorepos emitting thousands of chunks should raise [hash:8] to [hash:12] or [hash:16] across all three patterns at once. The patterns are plain Vite passthrough, documented in full in the Vite asset pipeline reference.

Why Moving a Component Re-Hashes More Than You Expect

Island boundaries are chunk boundaries. Move a shared helper from one island into another — or promote a child component so two islands now import it — and Rollup re-partitions the graph. Modules that were duplicated into two chunks may now be hoisted into one; modules that were shared may now be inlined. Both directions change the emitted bytes of chunks you did not edit, and every affected chunk gets a new hash and a cold edge cache entry.

Re-chunking blast radius Two panels of emitted files. Before the move, the runtime, counter and chart chunks all carry stable hashes. After a chart component is folded into the counter island, the runtime chunk keeps its hash but both the counter and chart chunks are re-hashed even though only one file was edited. Before: two islands After: merged island runtime-Ba9xK2mQ.js unchanged counter-CjK2p8Qa.js unchanged chart-B7nR4vTz.js unchanged move runtime-Ba9xK2mQ.js still cached counter-Dq9wYs2L.js re-hashed chart-Xa3mN7Pk.js re-hashed, now empty one edited file, two cold edge cache entries
Editing one component re-hashed two chunks; only the pinned runtime chunk survived with its edge cache entry intact.

client:only deserves separate attention here because it removes a node from the server graph rather than adding one. With client:only="react" Astro never renders the component on the server, so the page HTML contains an empty placeholder instead of markup. Two things follow. The HTML shrinks and its own bytes change, which is harmless because HTML is never fingerprinted. More subtly, any module that only that component imported now has no server-side consumer, so it appears in exactly one chunk instead of two — and both the client chunk and any chunk that previously shared it are re-emitted with new hashes.

Auditing Which Island Produced Which Hashed File

After any island refactor, the question is always the same: which of these files changed, and which island owns it? Astro emits enough metadata to answer it without guessing. Start with the raw listing, then join it against the manifest.

npx astro build

# 1. Every hashed JS file, newest build only
find dist/_astro -name '*.js' -printf '%f\n' | sort

# 2. Which HTML pages reference each chunk
for f in $(find dist/_astro -name '*.js' -printf '%f\n'); do
  refs=$(grep -rl "_astro/$f" dist --include='*.html' | wc -l)
  printf '%-34s referenced by %s page(s)\n' "$f" "$refs"
done

The second loop is deliberately crude: it greps the emitted HTML rather than trusting the manifest, so it catches the case where the manifest and the HTML disagree. That disagreement is rare but catastrophic, because the manifest is what your deploy script validates while the HTML is what browsers actually read.

A chunk referenced by zero HTML pages is either the shared runtime — reached through an import from another chunk rather than a <script> tag — or an orphan left behind by a stale build directory. Delete dist/ and rebuild before drawing conclusions from a zero.

To map chunks back to source files, read the manifest Vite writes alongside the output. Every entry keyed by a path under src/components/ is an island entry point, and its file value is the hashed chunk that island produced.

python3 - <<'EOF'
import json, pathlib

manifest = json.loads(pathlib.Path('dist/.vite/manifest.json').read_text())
for src, entry in sorted(manifest.items()):
    if not src.startswith('src/'):
        continue
    shared = ', '.join(entry.get('imports', [])) or 'none'
    print(f"{src}\n  -> {entry['file']}\n  shared imports: {shared}\n")
EOF
Island to hashed file lookup Four island source files on the left, the Vite manifest in the centre, and four hashed output files on the right. Each source path resolves through the manifest to exactly one hashed file, including the shared react-dom dependency which resolves to the client runtime chunk. Mapping hashed output back to islands island sources emitted _astro/ files src/components/Counter.jsx src/components/Chart.jsx src/components/Modal.jsx node_modules/react-dom dist/.vite/ manifest.json src to file Counter-CjK2p8Qa.js Chart-B7nR4vTz.js Modal-Dq9wYs2L.js runtime-Ba9xK2mQ.js
The Vite manifest is the join table between an island's source path and the hashed file it produced.

Wire that script into CI and diff its output between the base branch and the pull request. A reviewer then sees “this change re-hashed six chunks” as part of the review rather than discovering it from a cache-hit-ratio dip the next morning. The same manifest is the input to every downstream integrity and preload workflow described under asset manifest generation.

Directive Choice as a Caching Decision

Two islands with identical code but different directives have identical chunk hashes. That makes directive selection a pure runtime-performance decision, free of any cache-invalidation cost — which is exactly why it is worth tuning aggressively. Prefer client:visible for anything below the fold, client:idle for interactive controls that are not needed on first paint, and reserve client:load for components that must be interactive before the user can plausibly click them.

There is one place where the directive does touch caching, and it is in the HTML rather than the assets. Astro emits a <link rel="modulepreload"> for the chunks belonging to client:load islands so the browser can start fetching them during HTML parse. Deferred directives get no preload hint. Flipping a directive therefore changes which hashed URLs appear in the document head — the URLs themselves are unchanged, but a preload that disappears turns a parse-time fetch into a fetch initiated by the hydration script, which lands later and against a warm rather than cold connection. Measure the change on a throttled profile before assuming a deferred directive is strictly better.

client:media is the one directive with an asymmetric payoff. The chunk is still emitted and still downloaded by the hydration script, so it does not reduce bytes over the wire; it only avoids the hydration cost on non-matching viewports. If a component genuinely should not ship to mobile at all, the answer is a conditional render in the .astro file so the island is never emitted for that page, not a media directive.

When to Reconsider

You have more islands than components. Past roughly fifteen islands per page, the hydration script’s bookkeeping and the per-chunk request overhead outweigh the granularity. Merge related islands into one and accept a slightly larger chunk with a stabler hash.

Your runtime chunk changes on every release. That means application code is leaking into the node_modules chunk — usually because a barrel file re-exports both. Split the barrel, then re-run the manifest audit to confirm the runtime hash holds across two consecutive builds.

You are shipping one island to one page. A single small island on a single route may be cheaper as a plain <script> tag in the .astro file. Astro still bundles and hashes inline scripts, so you keep fingerprinting while skipping the framework runtime entirely.

Frequently Asked Questions

Does client:visible avoid downloading the component’s JavaScript?

No. The chunk is emitted, listed in the manifest, and preloaded by Astro’s hydration script; the directive only defers executing it until the element intersects the viewport. If your goal is to avoid the download, the component must not be an island at all.

Why did my island chunk hash change when I only edited a sibling component?

Because Rollup re-partitioned the chunk graph. Shared modules move between chunks as import relationships change, so a chunk you did not edit can be emitted with different bytes. Compare the imports arrays for both entries in dist/.vite/manifest.json across the two builds to see exactly which shared module moved.

Should I give each island its own manual chunk to stop hash churn?

Rarely. Forcing one chunk per island prevents Rollup from sharing common code, which inflates total transfer size for a marginal gain in hash stability. Pin the node_modules runtime chunk, leave island chunks to Rollup, and accept that small application chunks churn — they are small precisely because the expensive code is elsewhere.