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 .wasm module 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.

Process types in an Electron app and Wasm in each The main process is Node and loads modules with fs. Renderer processes are sandboxed Chromium pages that load modules like a website under the app's CSP. Utility processes are windowless Node processes suited to heavy Wasm work. The same .wasm file is packaged once for all of them. main process (Node) fs + WebAssembly.compile renderer (Chromium, sandboxed) fetch + instantiateStreaming, CSP utility process (Node) heavy work off main and renderers preload script narrow bridge between them app package one .wasm for every platform

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.

Finding the .wasm file in development and production In development the module is read from the project directory. In production it is read from the packaged resources directory or from inside the asar archive, depending on packaging configuration. Resolving paths through process.resourcesPath or app.getAppPath keeps both working. dev: project tree ./wasm/engine_bg. wasm packaged: resources process. resourcesPath/wasm or inside app.asar fs reads it transparently asarUnpack when a real file is needed one resolver function used everywhere

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.resourcesPath or app.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 nodeIntegration for 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.

Build matrix before and after replacing a native module Number of per-platform native build artefacts produced for each release, with a native Node module rebuilt for each platform and architecture, and with a single portable Wasm module. build artefacts per release native module (OS × arch × ABI) 9 Wasm module 1

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.

← Back to Wasm in Extensions & Desktop Apps