WebAssembly for desktop apps: what actually works in 2026
Published: 2026-08-05
WebAssembly outside the browser has been "almost ready" for years. Some of that is real progress and some is marketing, and the line between them is worth drawing precisely if you are deciding whether to build on it.
Here is what we found building a desktop app runtime on it.
What genuinely works
One binary, three systems. This is the promise and it holds. The same
.wasm bytes run unmodified on macOS, Windows, and Linux, on both Intel and
ARM. No conditional compilation, no per-platform CI matrix, no three sets of
release artifacts.
The size is real. A desktop app with a window, buttons, saved state, and drawing is typically 15 to 40 KB. Not a typo, and not a hello-world -- that is a working tool with persistence. An Electron equivalent is four orders of magnitude larger.
Startup is not a problem. A component loads and paints its first frame in tens of milliseconds. The old objection that Wasm is slow to start was about large modules and cold JIT; for apps this size it does not apply.
The component model is the part that matters. Plain WebAssembly gives you a
sandbox with integers. The component model gives you typed interfaces across
the boundary -- strings, records, lists, results -- defined in WIT and generated
into bindings on both sides. That is what makes "the app can call
canvas2d::fill_rect" a real statement rather than a convention about memory
offsets.
What does not work the way people imply
WASI is not free. WASI gives a guest a POSIX-shaped world: files, clocks, environment variables, sockets. That is exactly what you do not want if the point is a capability sandbox -- it hands over a filesystem and asks the host to police it afterwards.
Avoiding it is harder than it sounds, because the Rust standard library pulls
WASI in through paths that look unrelated. std::fs obviously. But also, in
practice: an allocation-error handler, a panic formatter, a timer someone
called once. We spent real time tracking down twenty leaked wasi:* imports
that traced back to Vec::with_capacity's out-of-memory path.
The fix is building the guest #![no_std] with your own allocator and panic
handler. It works, and it is not the getting-started experience anyone
advertises.
There is no UI story. The component model tells you how to pass a string across the boundary. It says nothing about how a guest opens a window, draws a button, or receives a click. Every project doing this invents its own interfaces, and they are all incompatible.
That is a real cost and worth being clear-eyed about: choosing this means choosing someone's UI world, not a standard.
Crates assume std. A large fraction of the ecosystem will not build
no_std. Image decoding, randomness, HTTP, JSON parsing -- for each one you
either find the rare no_std-compatible crate, or you bridge it. getrandom
needs a custom backend. Image decoding wants zune-* rather than image. None
of this is hard; all of it is unglamorous work nobody mentions in a blog post
about how great Wasm is.
The honest summary
WebAssembly delivers on portability and size, completely and impressively. It delivers nothing on user interface, and its default system interface actively works against a capability model.
If you are building a desktop app runtime on it, budget most of your effort for the parts Wasm does not give you: the UI interfaces, the host adapters for three windowing systems, and keeping the standard library out of your guests. The portable-bytes part is the easy half.
We think it is worth it, because the alternative is shipping three binaries and a code-signing certificate. But "just use Wasm" understates the work by a lot.
Krate is what we built on it: apps as single
.krate files that run on Mac, Windows, and Linux with a capability wall in
front of them. The source is public,
including all of the unglamorous parts.