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 web target.
  • [ ] 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.

A Wasm composable shared by several components The first component that calls useWasm starts a single dynamic import and initialisation. Every component receives the same reactive refs for the module and its status. When loading completes, all of them update; computed values using the module then recompute when their inputs change. component A calls useWasm() starts loading component B calls useWasm() joins same promise import + init once shared module state status → ready all components update computed(() => wasm.fn(x)) reactive results

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.

Ways to expose module state to Vue Storing the module in a deep ref wraps it in reactive proxies, which is costly and can break glue code. A shallowRef stores it as is and still triggers updates when replaced. Plain module-level variables are not reactive, so components would not update when loading finishes. ref(module) deep reactive proxy may break glue internals overhead on every access avoid shallowRef(module) stored as is updates when replaced safe for Wasm handles use this plain variable no reactivity components never update fine only inside functions not for shared state

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 ref around the module. Use shallowRef; 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 window or <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.

Initialising Wasm for five components Milliseconds of initialisation and megabytes of linear memory for five components using one module, loading it per component versus through a shared composable. ms spent initialising per-component init (5 instances) 175 ms shared composable (1 instance) 38 ms

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.

← Back to Full-Stack Frameworks with Wasm