Using Wasm in Astro Islands
This page answers one task: an Astro site — mostly static content — has a few interactive features that need WebAssembly: a code playground, an image compressor, a unit converter backed by a Rust library. You want those features to load their module only on the pages and at the moment they are needed, while every other page stays as light as Astro makes it by default.
Prerequisites
- [ ] An Astro project with a UI framework integration (React, Svelte, Preact, Vue or Solid) for interactive islands.
- [ ] A Wasm module with ES module glue (wasm-pack
--target webor similar). - [ ] Familiarity with Astro’s
client:*directives.
Islands and why they suit Wasm
Astro renders pages to static HTML at build time (or on the server) and ships no JavaScript by default. Interactive components become islands: a
component marked with a client:* directive is hydrated in the browser, with its JavaScript loaded only for that component. The directive controls when:
client:load hydrates immediately, client:idle when the browser is idle, client:visible when the component scrolls into view, and client:media when a
media query matches.
That model fits WebAssembly well. A Wasm module is a large asset that most visitors to most pages never need. Placing it inside an island, and importing it inside the island’s code, means the module is fetched only on pages that contain the island, and only when the directive fires. A visitor reading an article never downloads the playground’s compiler.
Step 1 — build the interactive component
Write the island as a normal framework component that loads the module on mount:
// src/components/Converter.tsx (Preact or React)
import { useEffect, useState } from "react";
export default function Converter() {
const [lib, setLib] = useState<null | typeof import("../wasm/units/units.js")>(null);
const [out, setOut] = useState("");
useEffect(() => {
let alive = true;
import("../wasm/units/units.js").then(async (m) => {
await m.default(); // init(): fetches units_bg.wasm via import.meta.url
if (alive) setLib(m);
});
return () => { alive = false; };
}, []);
return (
<div>
<input placeholder="3 ft in cm" onInput={(e) => lib && setOut(lib.convert(e.currentTarget.value))} disabled={!lib} />
<output>{lib ? out : "Loading converter…"}</output>
</div>
);
}
The dynamic import() creates a separate chunk for the glue, and the glue resolves the .wasm file relative to import.meta.url, which Vite (Astro’s
bundler) rewrites to the hashed asset URL in the build.
Step 2 — place the island with the right directive
---
// src/pages/tools/units.astro
import Layout from "../../layouts/Layout.astro";
import Converter from "../../components/Converter";
---
<Layout title="Unit converter">
<h1>Unit converter</h1>
<p>Type a quantity and a target unit.</p>
<Converter client:visible />
</Layout>
client:visible defers everything until the converter is on screen. For tools that are the page’s main purpose and above the fold, client:load starts
sooner; for secondary widgets, client:idle avoids competing with the page’s own loading. Pages without the island ship none of this code.
Step 3 — make Vite handle the .wasm asset
Vite treats new URL("./x.wasm", import.meta.url) in dependencies as an asset reference and emits the file with a hashed name. If glue code fetches the file
some other way, configure it explicitly or import the URL with Vite’s ?url suffix and pass it to init:
import wasmUrl from "../wasm/units/units_bg.wasm?url";
await m.default({ module_or_path: wasmUrl });
Check the production build: the .wasm file should appear under dist/_astro/ with a hash, and the deployed server must send application/wasm. Static
hosts generally do; verify with curl -I.
Step 4 — use Wasm at build time too
Astro components run at build time (or on the server in SSR mode), and they can call Wasm too — for example to render syntax highlighting with a Wasm-based highlighter, generate images, or format content — so the result ships as static HTML and visitors download nothing. Load the module in the component’s frontmatter using Node’s APIs:
---
import { readFile } from "node:fs/promises";
import init, { highlight } from "../wasm/hl/hl.js";
await init({ module_or_path: await readFile(new URL("../wasm/hl/hl_bg.wasm", import.meta.url)) });
const html = highlight(Astro.props.code, Astro.props.lang);
---
<pre set:html={html} />
This often removes the need for a client-side island entirely: if output depends only on content known at build time, compute it at build time.
Step 5 — measure page weight
Use the browser’s network panel or Lighthouse on a page without the island and one with it. The page without should show no Wasm or glue requests; the page
with should show them only after the island becomes visible. Track total transfer size per page in CI (for example with a small script over the built
dist/ and a list of key pages) so a change that accidentally imports the glue in a shared layout is caught.
Sharing a module between islands
If several islands on a page use the same module, each dynamic import() of the same glue resolves to the same module instance, so the .wasm file is
fetched and compiled once. Keep initialisation idempotent — init() called twice should not instantiate twice — by caching the init promise in a small
shared helper module that every island imports.
View transitions and island lifecycles
With Astro’s view transitions (client-side navigation), islands mount and unmount as users navigate without full page loads. A Wasm instance created by an
island survives in the shared glue module across navigations, which is good — no recompilation — but objects created per island must be freed when the island
unmounts (useEffect cleanup, onDestroy in Svelte). Otherwise memory grows as users move between pages that host the island.
Server islands and endpoints
Astro also supports server-side work outside the static build: API endpoints (src/pages/api/*.ts) and server islands, which render a component on the
server per request. Both can use WebAssembly in Node or in an edge adapter’s runtime. An endpoint that resizes uploaded images with a Wasm codec, or a server
island that renders a personalised chart with a Wasm rasteriser, keeps the module off the client entirely. The loading code differs by adapter: Node adapters
read the .wasm file from disk, while edge adapters (Cloudflare, Vercel Edge, Netlify Edge) usually import .wasm files as compiled modules through their
bundlers. Keep the module’s loading behind a small helper with one implementation per environment, and make sure the adapter’s build copies the .wasm
file into the deployed output — the same missing-file problem that affects serverless functions in general.
Progressive enhancement for islands
An island that needs Wasm should still render something useful before the module arrives — and if it never does, because the browser blocks it, the user is offline, or the download fails. Render the island’s server-side HTML as a usable fallback: a form that submits to an endpoint, a static result for a default input, or at least an explanation. The island then upgrades in place once the module is ready. This keeps the page meaningful for crawlers and for users on slow connections, and matches Astro’s philosophy of HTML first, JavaScript as an enhancement.
Expected output
Article pages ship zero JavaScript and no Wasm; the unit-converter page loads the converter island when it scrolls into view, then fetches a 90 KB compressed
module from /_astro/units_bg.[hash].wasm; the code samples across the site are highlighted at build time with a Wasm highlighter; and a CI check fails if any
article page requests a .wasm file.
Gotchas
- Importing glue in a shared layout. Every page pays. Import only inside islands.
client:loadfor below-the-fold tools. Loads early for nothing. Useclient:visibleorclient:idle.- Glue that fetches a hard-coded path. Breaks with hashed assets. Use
?urlorimport.meta.url. - Client-side Wasm for build-time work. Compute static output at build time.
- Not freeing per-island objects with view transitions. Memory grows. Clean up on unmount.
- Islands with no fallback HTML. Users see nothing if Wasm fails. Render a usable server-side version first.
Performance note
Moving the converter’s Wasm from the global layout into a client:visible island removed 120 KB (compressed) from every article page and improved their
Largest Contentful Paint by 180 ms on a throttled mobile profile.
Frequently Asked Questions
Can an Astro component itself be an island without a framework?
Interactivity needs a client script; plain <script> tags in Astro components work too and are bundled per page.
Does SSR mode change anything? Server-rendered pages follow the same island rules; build-time Wasm calls become request-time calls.
Which framework is best for Wasm islands? Any; smaller runtimes such as Preact or Svelte keep the island’s own JavaScript light.
Can islands share state with Wasm? Through a shared module or nanostores; keep the Wasm instance in a shared module.
Can Astro endpoints use Wasm on the server? Yes — Node adapters read the file from disk and edge adapters import it as a module; keep loading behind a per-environment helper.
What should an island show before its module loads? Server-rendered fallback HTML that is useful on its own — a form, a default result or a short explanation — then upgrade in place.
Related
- Lazy loading Wasm on first use — the general technique.
- Bundling Wasm ESM with Vite — Vite asset handling.
- Using Wasm in a SvelteKit app — another Vite-based framework.
- Progressive enhancement with Wasm — static first, Wasm second.
← Back to Full-Stack Frameworks with Wasm