Shipping a browser app as a desktop app with Tauri — where native numerics pay off
Equana's desktop build runs the exact same React UI in a Tauri shell, but dispatches numerical kernels to native OpenBLAS, Apple Accelerate, and SuiteSparse. Here's the dispatch chain, and where native actually wins.
Equana is a browser notebook — a TypeScript interpreter with WebAssembly numerical kernels, running entirely in a tab. The desktop app is the same React UI, the same interpreter, the same .wasm kernels, wrapped in a Tauri 2 shell. It adds exactly one thing: when a native BLAS, LAPACK, or sparse library is installed on the machine, the heavy kernels dispatch to it instead of to WebAssembly. That's the whole pitch. The interesting engineering is in how a browser-shaped app grows a native fast path without forking the codebase — and in the places where native still isn't worth it.
Same UI, native shell
Tauri wraps a web frontend in the OS's native webview and gives it an IPC bridge to a Rust backend. So the desktop build ships the identical React Router frontend and loads it in the webview; nothing in the UI layer knows or cares that it's not in Chrome. The WASM kernels are still there and still the default. What changes is that the Rust backend exposes a set of commands — numwa_call_native_lib, numwa_call_native_binary, numwa_alloc, numwa_read, numwa_free — that the interpreter can call to run arithmetic outside the sandbox, on libraries linked at native speed.
The seam is clean because the interpreter already dispatches through an abstraction. Equana's numerical calls go through registries, and there's a Tauri-backed implementation of each: TauriNativeLibRegistry for calling into a shared library like OpenBLAS (CBLAS/LAPACK entry points), and TauriNativeBinRegistry for invoking standalone native binaries. Both are thin marshallers that turn interpreter values into a Tauri invoke() and turn the response back into notebook arrays. On the web those registries simply don't exist, and dispatch falls through to WASM.
The dispatch chain: native → WASM → TS
Every numerical operation in Equana resolves through the same three-tier fallback, best available first:
- Native — if a Tauri registry is present and the operation has a native binding and the library was found on this machine, run it native.
- WASM — otherwise run the WebAssembly kernel. This is the default everywhere and always works in a modern browser.
- TypeScript — if a WASM binary isn't available for this operation, run the pure-TS fallback. Slower, but the notebook never breaks.
The TS fallback is mandatory for every operation; the WASM kernel covers the hot path across all environments; native is a desktop-only accelerator layered on top. Crucially, dispatch is per-operation, not per-session: a single notebook can run gemm native, an FFT on WASM, and some niche reduction on the TS fallback, all in the same cell, because each call independently picks the best backend that can service it.
Here's what a native gemm call actually threads through. The interpreter hands TauriNativeLibRegistry.call the operation name and the operand handles. The registry marshals the handle IDs, scalars (alpha, beta), and integer dimensions (m, n, k, and leading dimensions lda/ldb/ldc), then invokes the Rust command numwa_call_native_lib. On the Rust side that lands in dispatch_gemm, which unpacks the arguments and calls straight into cblas_dgemm in whatever native BLAS was loaded. Note the shape of those arguments — pointer, dimensions, leading dimensions, alpha, beta — is exactly the classic BLAS dgemm signature. Equana's array handles were BLAS arguments all along, which is why the native call needs no translation layer: the handle already is what CBLAS wants.
Which libraries, and how they're chosen
The Rust backend probes the machine at startup and loads the first library it can dlopen from an ordered candidate list. For dense linear algebra it looks for:
- OpenBLAS first —
libopenblas.so/.dylib/.dll. It's the highest priority because it performs well on both Intel and AMD. - Apple Accelerate on macOS — the
Accelerate.framework, whose vecLib provides both BLAS and LAPACK. A good fallback when OpenBLAS isn't installed. - Intel MKL last, deliberately: MKL disables its AVX-512 codepaths on non-Intel CPUs, so making it the default would penalize AMD hardware. It's a candidate, but the lowest-priority one.
LAPACK resolves through the same idea — liblapack, or the LAPACK that OpenBLAS bundles, or Accelerate's vecLib libLAPACK on macOS. And sparse factorizations dispatch to SuiteSparse (the AMD ordering, CHOLMOD, and friends), because that's where the browser has the least to offer. The desktop settings UI lists what was detected and lets you force auto, native, or wasm per your taste — including pinning everything to WASM so the desktop build behaves identically to the web one.
Where native actually pays off
This is the part worth being precise about, because the answer surprised me. Take dense dgemm, Float64, on a Ryzen 9950X. Native OpenBLAS with all threads runs around 400 GFLOPS. Equana's multi-threaded WebAssembly kernel on the same machine reaches 410. The browser is not a rounding error behind native on dense matmul — on this workload it's level. Five years ago that sentence would have been absurd; today, with WASM SIMD and threads over shared memory, a well-tuned WASM GEMM lands in the same league as a hand-tuned native BLAS. So if dense matrix multiply were the whole story, the desktop app would have almost no reason to exist.
It isn't the whole story. Native wins decisively in two places where WASM has no equivalent tuned code:
- LAPACK-heavy workloads. Dense factorizations and solvers — LU, QR, SVD, eigenvalue problems — are where decades of LAPACK tuning, blocked algorithms, and multi-threaded panel factorizations compound. Equana ships WASM and TS paths for these, but matching LAPACK's tuned blocking in the browser is a much taller order than matching a single GEMM, and native simply has the better kernels today.
- Sparse direct solvers. Sparse Cholesky and LU via SuiteSparse involve symbolic analysis, fill-reducing orderings, and supernodal kernels that represent person-decades of work. There is no browser-native equivalent, so on a large sparse factorization the native path isn't "faster" — it's in a different complexity regime.
So the split is this: dense matmul is a tie, and everything with irregular structure or deep LAPACK blocking goes to native by a wide margin. That's precisely the workload profile where a desktop install earns its place.
Why the browser stays primary
Given all that, why isn't desktop the flagship? Because the browser build is the one that has no install step, no platform matrix, no "is OpenBLAS on your LD_LIBRARY_PATH" support tickets — you open a URL and compute. The WASM path is the contract: it runs everywhere, the TS fallback guarantees every operation works, and the desktop app is strictly additive. It takes the identical UI and interpreter and, when the machine happens to have great native libraries installed, quietly routes the LAPACK and sparse work to them. Nothing about the desktop build is a different product; it's the web app plus a native fast path that engages when it can and gets out of the way when it can't.
That additive design is only possible because the dispatch abstraction was there from the beginning — the handle is a BLAS argument, the registry is a swappable backend, and "native" is just one more entry in a fallback chain that ends at pure TypeScript. You can try the browser version today at equana.dev. For how one generic function body targets native, WASM, and TS at once, read same code, three backends; and for the kernel-tuning work behind that 410 GFLOPS number, the dgemm optimization diary.