Using Wasm in a Vue App
This page answers one task: several components in a Vue 3 application need functions from one WebAssembly module, and you want it loaded once, exposed reactively — with loading and error states — and used without leaking memory or blocking the UI.
Prerequisites
- [ ] A Vue 3 project with Vite (or Nuxt 3).
- [ ] A Wasm package with ES-module glue, such as wasm-bindgen’s
webtarget. - [ ] Familiarity with the Composition API (
ref,computed, composables).
The shape of a good integration
The module should be loaded lazily, exactly once, no matter how many components ask for it, and every component should see the same loading state. Results computed by the module should behave like any other reactive value: when inputs change, results update. Objects that live in Wasm memory — exported Rust structs — must be freed when the component that created them goes away. And nothing about the module should leak into server-side rendering if the app uses Nuxt.
A composable provides all of that in one place. It holds module-level state shared by every caller, starts loading on first use, and returns reactive refs for the module, its status and any error.
Step 1 — write the composable
// src/composables/useWasm.ts
import { shallowRef, ref } from "vue";
import type * as Engine from "@/wasm/textkit.js";
const module = shallowRef<typeof Engine | null>(null);
const status = ref<"idle" | "loading" | "ready" | "error">("idle");
const error = shallowRef<unknown>(null);
let loading: Promise<void> | null = null;
export function useWasm() {
if (!loading && typeof window !== "undefined") {
status.value = "loading";
loading = import("@/wasm/textkit.js")
.then(async (m) => { await m.default(); module.value = m; status.value = "ready"; })
.catch((e) => { error.value = e; status.value = "error"; loading = null; });
}
return { module, status, error };
}
shallowRef matters: a deep ref would make Vue wrap the module namespace object — and any Wasm-backed objects stored in it — in reactive proxies, which
can break identity checks in the glue and costs performance for no benefit. The state lives at module scope, so every component shares it. Resetting
loading on failure lets a later call retry. The typeof window guard keeps loading out of server rendering.
Export a retry() from the same file that clears error, sets loading back to null and calls useWasm() again, so components have one function to
call when a load fails.
Step 2 — use it reactively in components
<script setup lang="ts">
import { ref, computed } from "vue";
import { useWasm } from "@/composables/useWasm";
const { module, status } = useWasm();
const input = ref("");
const stats = computed(() => module.value ? module.value.analyze(input.value) : null); // { words, sentences, readability }
</script>
<template>
<textarea v-model="input" :disabled="status !== 'ready'" />
<p v-if="status === 'loading'">Loading analyser…</p>
<p v-else-if="stats">{{ stats.words }} words · readability {{ stats.readability.toFixed(1) }}</p>
</template>
The computed re-runs whenever input changes, and once when the module becomes ready. For cheap functions this is all that is needed. For functions
that take more than a few milliseconds, debounce the input or move the work into a worker (step 4), because computed runs synchronously during
rendering.
Step 3 — free Wasm objects on unmount
If the module exports Rust structs — a parser holding a document, an image buffer — the JavaScript wrappers must be freed explicitly. Tie that to the component’s lifecycle:
import { onBeforeUnmount, shallowRef, watch } from "vue";
const doc = shallowRef<InstanceType<typeof Engine.Document> | null>(null);
watch(module, (m) => { if (m && !doc.value) doc.value = new m.Document(); }, { immediate: true });
onBeforeUnmount(() => { doc.value?.free(); doc.value = null; });
Without the free(), every mount of the component leaks the struct’s memory inside the module, which never shrinks. Enabling wasm-bindgen’s weak
references adds a safety net, as in
freeing Wasm objects with FinalizationRegistry,
but explicit frees keep memory predictable.
Step 4 — move heavy work into a worker
For work that takes tens of milliseconds or more, put the module in a worker and make the composable expose async functions instead of the raw module. A Comlink-wrapped worker fits naturally:
const api = Comlink.wrap<WorkerApi>(new Worker(new URL("../workers/textkit.worker.ts", import.meta.url), { type: "module" }));
const result = shallowRef<Stats | null>(null);
watchDebounced(input, async (text) => { result.value = await api.analyze(text); }, { debounce: 120 });
The UI stays responsive while the worker computes, and stale results can be discarded by tagging requests. The worker setup is covered in wrapping a Wasm worker with Comlink.
Step 5 — Nuxt and server-side rendering
In Nuxt, components render on the server first. Keep Wasm loading client-side: the typeof window guard in the composable does this, and components can
wrap Wasm-dependent parts in <ClientOnly> so the server renders a placeholder. If the module should also run on the server — for example to render
content — load it in a server route or plugin with a Node loader, as described for SvelteKit in
using Wasm in a SvelteKit app;
the same split between browser and server loaders applies.
Handling errors and retries in the UI
A module can fail to load — a flaky network, a blocked asset, an old browser without a required WebAssembly feature — and the UI should say so rather
than leave a disabled control forever. The composable already exposes status and error; render them. Show a short message with a retry button when
status is "error", and have the button call a retry() function the composable exports, which clears the error and starts loading again. Distinguish
the causes when you can: a TypeError from fetch suggests the network, a WebAssembly.CompileError suggests an unsupported feature or a corrupted
download, and a RuntimeError during initialisation suggests a bug. For unsupported browsers, check required features up front, as in
detecting proposal support at runtime,
and offer a fallback or an explanation instead of an endless retry. Report load failures to your error tracker with the error class and the browser
version, which quickly reveals whether a release broke loading for a particular platform.
Testing components that depend on the module
Component tests with Vitest and Vue Test Utils run in Node with a simulated DOM, where the browser loader’s fetch of a relative URL may not work. Two
approaches keep tests fast and reliable. For unit tests of components, mock the composable: return a shallowRef holding a small fake object with the
functions the component calls, and a status ref you can set to "loading", "ready" or "error" to test each state of the template. For integration
tests, load the real module through a Node-compatible path — initSync with bytes read from disk — in a test setup file, and let the composable use it.
Keep at least one end-to-end test in a real browser with Playwright that loads the production build, so the actual asset path and the
import.meta.url resolution are exercised; that is where packaging mistakes show up.
Expected output
Three components using useWasm() trigger a single network request for the .wasm file; all show “loading” and then update together; text statistics
update as the user types; navigating away and back a hundred times leaves the module’s memory size unchanged; and the Nuxt build renders on the server
without errors.
Gotchas
- Deep
refaround the module. UseshallowRef; proxies interfere with Wasm glue. - Loading per component. Keep the loading promise at module scope so it is shared.
- Forgetting
free()on unmount. Wasm memory grows with every mount. - Heavy work in
computed. It blocks rendering. Debounce or use a worker. - No error state in the UI. A failed load leaves controls disabled forever. Show the error and offer a retry.
- Loading during SSR. Guard with
typeof windowor<ClientOnly>.
Performance note
With the composable, a page with five Wasm-using components made one 160 KB (compressed) request and initialised once in 38 ms. A naive version that imported and initialised in each component made five requests on a cold cache before HTTP caching kicked in, and initialised five instances, using five times the linear memory.
Frequently Asked Questions
Can I use Pinia instead of a composable?
Yes — a store with the same shallowRef state works the same way and adds devtools visibility.
Should the module be a Vue plugin?
A plugin that provides the composable’s state via provide/inject is useful when you want per-app instances, such as in tests.
How do I show progress for large modules?
Fetch the .wasm with a progress-reporting reader, then pass the bytes to init.
Does v-model work with Wasm-backed objects?
Bind to plain JavaScript state and pass values into the module; do not bind directly to Wasm object fields.
Can I use the module inside a Vue directive? Yes, but keep directives thin; call the composable’s functions from the directive hooks and avoid storing Wasm objects on DOM elements.
Does Vue’s reactivity work with Wasm-returned arrays? Yes for copied JavaScript arrays. Do not make typed-array views over Wasm memory reactive; copy the values you display instead.
Related
- Integrating Wasm into a React app — the React equivalent with hooks.
- Designing a promise-based API around a Wasm module — the wrapper behind the composable.
- Exporting Rust structs as JavaScript classes — the objects that need freeing.
- Bundling Wasm ESM with Vite — the build side.
← Back to Full-Stack Frameworks with Wasm