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.
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.
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
fetchor 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.
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.
Related
- Using Wasm in a Tauri app — native versus webview in Tauri.
- Targeting Node and browsers from one Wasm package — packaging for several runtimes.
- Designing a promise-based API around a Wasm module — the JavaScript surface.
- Sharing validation logic between server and browser — a smaller shared core.
← Back to Wasm in Extensions & Desktop Apps