Running Wasm in Electron Apps
This page answers one task: an Electron desktop app needs WebAssembly — to replace a native Node module, to share code with the web version, or to run a compute-heavy library — and you need it to load reliably from the packaged app on Windows, macOS and Linux.
Prerequisites
- [ ] An Electron app (recent versions, with context isolation enabled — the default).
- [ ] A
.wasmmodule and its loader. - [ ] A packaging tool such as electron-builder or Electron Forge.
Where Wasm can run in Electron
An Electron app has several kinds of process, all with the V8 engine and full WebAssembly support. The main process is Node: it can load modules with
fs exactly as in
loading Wasm in Node.js with ES modules.
Renderer processes are Chromium pages: they load modules like a website, subject to the page’s content security policy, and — with context isolation
and the sandbox enabled, as they should be — without Node APIs. Utility processes (utilityProcess.fork) are Node processes without a window,
designed for heavy background work. Choosing where a module runs is the main design decision: UI-adjacent work in the renderer, heavy or privileged work
in a utility process, coordination in main.
The big practical win of WebAssembly in Electron is distribution. Native Node modules must be rebuilt for every Electron version, operating system and
architecture with electron-rebuild, and are a frequent cause of broken builds and crashes on users’ machines. A .wasm module is one portable file that
works in every process type and every platform, which is why many Electron apps have moved image processing, compression and database engines to Wasm.
Step 1 — load in the renderer like a web page
Renderer code is web code. With a bundler (Vite, webpack), import the module the same way as for a website; the bundler emits the .wasm file and a URL
relative to the page. The page’s CSP must allow compilation:
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'">
Pages loaded from file:// URLs have quirks — fetch of file: URLs and streaming compilation behave differently across Electron versions — so many
apps serve the renderer from a custom protocol (app://) registered with protocol.handle, which also lets them set proper Content-Type: application/wasm
headers. That keeps the renderer’s loading code identical to the web version.
Step 2 — load in main or a utility process with Node APIs
In Node contexts, resolve the file relative to the app and read it from disk:
// utility/worker.js — started with utilityProcess.fork()
import { readFile } from "node:fs/promises";
import path from "node:path";
const wasmPath = path.join(process.resourcesPath, "wasm", "engine_bg.wasm"); // packaged location
const module = await WebAssembly.compile(await readFile(wasmPath));
const { exports } = await WebAssembly.instantiate(module, imports);
process.parentPort.on("message", ({ data }) => {
process.parentPort.postMessage({ id: data.id, result: run(exports, data.input) });
});
In development, files live in the project tree; in production, they live inside the app bundle, possibly inside an asar archive. process.resourcesPath
and app.getAppPath() resolve correctly in both if the packaging configuration puts the files where the code expects.
Step 3 — handle asar archives
Electron packages application files into an app.asar archive. Node’s fs is patched to read from it, so readFile works on paths inside the archive.
But anything that needs a real file — a native module, a child process, some tools that memory-map files — does not. For .wasm files read with readFile,
asar is fine; if a loader insists on a real path, configure the packager to unpack those files:
{ "build": { "asarUnpack": ["wasm/**/*.wasm"], "extraResources": [{ "from": "wasm", "to": "wasm" }] } }
extraResources places files next to the archive, under process.resourcesPath, which is often the simplest arrangement.
Step 4 — move heavy work off the UI
WebAssembly calls are synchronous: a long call in a renderer freezes that window, and a long call in the main process freezes every window, because
the main process handles window management and IPC. Put heavy modules in a utility process or a Web Worker inside the renderer, and communicate with
MessagePorts, transferring buffers instead of copying them. The pattern matches the browser advice in
keeping the UI responsive during long Wasm tasks.
Step 5 — replace a native module
If the motivation is retiring a native Node module, follow the migration in
replacing a native Node addon with Wasm:
compile the library’s core to Wasm, keep its JavaScript API, move file and network I/O into JavaScript, and run the work in a utility process. Remove
electron-rebuild from the build once nothing native remains. The CI matrix shrinks — no per-platform native compilation — and crashes from ABI
mismatches after Electron upgrades disappear.
Security considerations
Electron’s security model relies on keeping renderers sandboxed and away from Node APIs, with a small, explicit preload bridge. WebAssembly fits that
model well, as long as the module’s imports respect it. In a renderer, a module should receive only the imports the page needs; it should not be given
functions that proxy arbitrary file access or IPC. In a utility process, a module may have broader capabilities, but the IPC messages that drive it should
be validated like any untrusted input, since a compromised renderer could send them. If the app runs user-supplied or third-party modules — plugins,
scripts — run them in a separate utility process with minimal imports, or in a dedicated runtime with resource limits, as described in
sandboxing untrusted code with Wasm.
Keep the CSP strict apart from 'wasm-unsafe-eval', and never enable nodeIntegration in renderers to make a loader work.
Updates and versioning
Desktop apps update differently from websites: users may run an old version for months, and auto-updaters replace the whole bundle at once. That is
helpful for WebAssembly — the module and its glue always update together, so the version mismatches common on the web cannot happen within one install.
It does mean that module bugs persist until users update, so embed a version in the module (an exported version() or a custom section), report it with
crash data, and make sure the update channel can deliver fixes quickly. If the app downloads additional modules after installation — language packs,
models — verify their signatures before compiling them, since they bypass the code-signing of the app bundle.
Testing the packaged app
Many Electron Wasm bugs exist only in the packaged build: paths that resolve differently, files left out by the packager, protocol handlers that set the wrong headers, code signing that changes file permissions. Run the end-to-end tests against the packaged application, not only against the development server. Playwright supports Electron directly, launching the built executable and driving its windows. A minimal test that opens the window, triggers the Wasm feature and checks the result, run on each target platform in CI, catches the majority of packaging regressions before they reach users.
Expected output
The packaged app loads engine_bg.wasm from process.resourcesPath on all three platforms, runs heavy work in a utility process without freezing windows,
the renderer compiles its own module under a CSP with 'wasm-unsafe-eval', and the build no longer runs electron-rebuild.
Gotchas
- Relative paths that work only in development. Resolve through
process.resourcesPathorapp.getAppPath(). file://loading quirks. Serve renderers from a custom protocol with proper headers.- Heavy calls in the main process. They freeze every window. Use a utility process.
- Enabling
nodeIntegrationfor convenience. It undermines Electron’s security model. Keep renderers sandboxed. - Unverified downloaded modules. Verify signatures before compiling.
Performance note
Moving an image library from a native module to Wasm in a utility process made processing about 15% slower on the same machine, but removed 9 per-platform native builds from CI and eliminated a class of crashes after Electron upgrades. Compiling the 1.6 MB module took about 30 ms once per process.
Frequently Asked Questions
Can renderers and the main process share one compiled module? They are separate processes, so each compiles its own; the code cache reduces repeat compile cost.
Does Electron support Wasm threads?
Yes, with SharedArrayBuffer available in isolated renderers and in Node contexts.
Should I use WASI in Electron?
For WASI command-line tools, run them in a utility process with node:wasi; for libraries, plain Wasm is simpler.
Is Wasm in Electron faster than in the browser? It is the same V8 engine; performance matches Chrome of the same version.
How do I test the packaged app automatically? Playwright can launch the built Electron executable; run a smoke test of the Wasm feature on each platform in CI.
Related
- Using Wasm in a Tauri app — the Rust-based alternative.
- Packaging Wasm files in desktop installers — installers and signing.
- Sharing one Wasm core across web and desktop — one module everywhere.
- Running Wasm off the event loop in Node.js — background work in Node.
← Back to Wasm in Extensions & Desktop Apps