Sharing One Wasm Core Across Web and Desktop

This page answers one task: the same product ships as a website, a desktop app and perhaps a browser extension or mobile app, and you want its core logic — the document model, the file format, the search engine, the sync algorithm — implemented once, compiled to WebAssembly, and run identically in every shell.

Prerequisites

  • [ ] Core logic in Rust, C/C++, Go or another language that compiles to Wasm, separated from UI code.
  • [ ] At least two host shells (for example a web app and an Electron or Tauri app).
  • [ ] A test runner that can load the module in Node and in a browser.

Why a shared core, and what makes it hard

Products that exist on several platforms tend to grow several implementations of the same logic: a TypeScript version for the web, a Swift version for iOS, a Kotlin version for Android, perhaps a server version. They drift apart, and users notice when a document opened on one device behaves differently on another. A WebAssembly core is one way to stop the drift: one implementation, compiled once, running in every host that has a WebAssembly engine — which today is every browser, every webview, Node, Electron and the server runtimes.

The hard part is not compiling the core; it is keeping it host-agnostic. The moment the core calls fetch, touches localStorage, reads a file path, spawns a thread or asks for the time zone, it depends on a specific host. The design that works is a core that does only computation and talks to the outside world through a narrow interface of host capabilities, with a small adapter per shell that implements those capabilities using whatever that host provides.

One core, several hosts The shared Wasm core contains only domain logic and calls a narrow host interface for storage, files, time and logging. Each shell provides an adapter implementing that interface with its own APIs: IndexedDB and fetch in the browser, the file system in Electron, Tauri commands in Tauri, and native plugins on mobile. shells web app, extension, Electron, Tauri, mobile host adapters one per shell, small host interface storage, files, clock, log, random shared Wasm core domain logic, no host assumptions one test suite runs against every adapter

Step 1 — define the host interface

List what the core needs from outside, in domain terms, and keep the list short:

pub trait Host {
    fn now_ms(&self) -> f64;
    fn random_bytes(&self, buf: &mut [u8]);
    fn log(&self, level: u8, msg: &str);
    fn load_blob(&self, key: &str) -> Option<Vec<u8>>;     // persistent storage, not file paths
    fn save_blob(&self, key: &str, data: &[u8]);
}

“Load the blob called settings” can be implemented with IndexedDB, a file in the app’s data directory, Android preferences or a server call. “Read /home/user/.app/settings.json” cannot. Prefer synchronous, simple operations in the interface where hosts can satisfy them; where an operation is inherently asynchronous everywhere — network requests — keep it out of the core entirely and let shells fetch data and hand it in.

Step 2 — wire the interface through imports

With wasm-bindgen, declare the host functions as imports from a JavaScript module that each shell provides, or pass a host object into an initialiser:

#[wasm_bindgen]
extern "C" {
    pub type JsHost;
    #[wasm_bindgen(method)] fn now_ms(this: &JsHost) -> f64;
    #[wasm_bindgen(method)] fn log(this: &JsHost, level: u8, msg: &str);
    #[wasm_bindgen(method)] fn load_blob(this: &JsHost, key: &str) -> Option<Vec<u8>>;
    #[wasm_bindgen(method)] fn save_blob(this: &JsHost, key: &str, data: &[u8]);
}

#[wasm_bindgen]
pub struct Core { host: JsHost, state: State }

#[wasm_bindgen]
impl Core {
    #[wasm_bindgen(constructor)]
    pub fn new(host: JsHost) -> Core { Core { state: State::load(&host), host } }
    pub fn apply(&mut self, op: &[u8]) -> Vec<u8> { self.state.apply(op, &self.host) }
}

Each shell constructs new Core(hostAdapter) with its own adapter object. For the Component Model, the same interface becomes a WIT world whose imports each host implements, as in writing your first WIT interface.

Step 3 — write one adapter per shell

// web adapter: IndexedDB-backed storage loaded at startup, console logging
export function webHost(cache) {
  return {
    now_ms: () => Date.now(),
    log: (level, msg) => console[level > 2 ? "error" : "log"](msg),
    load_blob: (key) => cache.get(key) ?? undefined,
    save_blob: (key, data) => { cache.set(key, data.slice()); persistLater(key, data); },
  };
}

An Electron adapter backs save_blob with files under the user data directory; a Tauri adapter calls a command; a mobile adapter uses a native storage plugin. Each adapter is a few dozen lines, and it is the only shell-specific code that touches the core.

How each shell implements the host interface The browser uses IndexedDB and console logging. A browser extension uses chrome.storage. Electron uses files in the user data directory. Tauri uses a Rust command. A Capacitor mobile app uses a native preferences or file plugin. The core sees the same interface in every case. shell storage logging web app IndexedDB (preloaded cache) console extension chrome.storage console / service worker Electron files in userData main-process logger Tauri Rust command Rust tracing Capacitor native storage plugin native log

Step 4 — detect capabilities instead of hosts

Hosts differ in WebAssembly features — threads, SIMD, memory limits — independent of which shell they are. Detect features at startup and choose the build or configuration accordingly, rather than branching on “is this Electron”. A desktop shell with SharedArrayBuffer gets the threaded build; a mobile webview without isolation gets the single-threaded one. The same code path then behaves correctly in shells you have not yet built.

Step 5 — test the core once, run it everywhere

Test the core’s logic natively (cargo test) with a mock host. Then run a shared integration suite against the compiled module with each adapter — in Node for the Electron-style adapter, in a headless browser for the web adapter — so that adapter bugs are caught as well. Version the core and its host interface together; when the interface changes, every adapter changes in the same commit, and a compile-time or startup check ensures shells never load a core expecting a different interface version.

Data formats and compatibility across shells

A shared core usually also owns the data format — documents, project files, sync payloads — and that format outlives any single release. Version it inside the core, migrate old versions on load, and never let a shell write data in a newer format that an older shell on another device cannot read without warning. Since users run different shells at different versions (the website updates instantly, desktop apps when users accept updates, mobile apps through store review), the core must handle data from versions older and newer than itself: refuse newer data with a clear message, upgrade older data transparently. Keeping that logic in the core, rather than in each shell, is one of the strongest arguments for sharing it.

Size, loading and where the core should not go

The same binary in every shell also means the same size everywhere, so keep the core focused: domain logic only, no UI, no networking libraries, no large dependencies that only one shell needs. Shell-specific features belong in the shells or in additional, optional modules. And some logic should not be shared: platform integrations, accessibility, input handling and rendering are better written natively per shell, where they can follow each platform’s conventions. The core is the part where identical behaviour matters more than native feel.

Releasing the core independently

Once several shells depend on the core, it becomes a product of its own with consumers that update at different speeds. Publish it as a versioned package — an npm package with the .wasm and glue, or an internal artefact — with release notes, and let each shell upgrade deliberately. Semantic versioning of the public interface tells shell teams what to expect. A shared changelog that lists data-format changes separately from API changes helps shells with slow release cycles, such as mobile apps waiting for store review, plan their upgrades.

Expected output

The document engine compiles to one .wasm file used by the website, the Electron app, the Chrome extension and the Capacitor app; each shell has an adapter of under 100 lines; the shared integration suite passes against every adapter in CI; and a document edited on one device opens identically on all the others.

Gotchas

  • Host-specific calls inside the core. One fetch or file path ties it to a shell. Route through the host interface.
  • Async-everywhere interfaces. They complicate the core. Keep async work in shells and hand data in.
  • Branching on host names. Detect capabilities instead.
  • Adapters drifting. Test every adapter with the shared suite.
  • Unversioned data formats. Shells update at different speeds. Version and migrate in the core.

Performance note

The adapter layer added under 1% to typical operations because host calls were rare — mostly at load and save time. The core module was 410 KB (140 KB with Brotli) and loaded in 20–60 ms depending on shell and device.

Loading the shared core in each shell Milliseconds to compile and instantiate the same 410 KB core module in a desktop browser, an Electron app, a Chrome extension service worker, and a Capacitor app on a mid-range Android phone. ms to a ready core desktop browser 21 ms Electron app 19 ms extension service worker 24 ms Capacitor, mid-range Android 58 ms

Frequently Asked Questions

Should the core be a component or a wasm-bindgen module? wasm-bindgen is simplest for JavaScript shells today; the Component Model is attractive when native hosts like wasmtime are among the shells.

Can the server use the same core? Yes — in Node, Deno or a Wasm runtime, with a server adapter, or natively from the same source.

How do I handle threads in some shells only? Ship threaded and single-threaded builds of the core and pick by capability at startup.

What about UI code? Keep it in the shells, or share it separately with a cross-platform UI framework; the core should stay UI-free.

Who should own the core? A team that serves all shells, with shell teams as its consumers; ownership by one shell tends to bias the interface towards that shell.

← Back to Wasm in Extensions & Desktop Apps