Equana

All articles
July 23, 2026·Max Schurigwebassemblyarchitectureperformance

One .wasm file per function: lazy-loading a numerical library

Instead of one big WebAssembly module, Equana ships each kernel as its own tiny .wasm binary, fetched the first time you call it and selected for your exact hardware.

Most projects that compile a numerical library to WebAssembly ship one module. You take BLAS, LAPACK, an FFT library, compile the whole thing, and the browser downloads a multi-megabyte .wasm blob before the first + runs. Equana does the opposite. Each kernel is its own binary: gemm_f64.wasm is one file, gemm_f32.wasm is another, fft_f64.wasm another, and so on across the entire numerical surface. A notebook that only ever multiplies two matrices downloads exactly the matrix-multiply kernel and nothing else.

This looks wasteful next to a single linked module — more files, more requests, no cross-function inlining. On the web it inverts into an advantage, because the axis that matters is not link-time optimization, it is what the user has to fetch, when, and how the cache and the CDN treat it. This post walks the machinery: the lazy registry that fetches on first call, the manifests that describe each package, and the per-hardware variant selection that picks the right binary for your CPU.

Fetch on first call

The core is a class called LazyWasmRegistry. It wraps a live WASM registry (the thing that actually instantiates modules and dispatches calls into them) and a BinaryProvider (the thing that knows how to fetch bytes). Its call method is the whole idea in a dozen lines:

async call(name: string, args: EqValue[], span: Span): Promise<EqValue> { try { return this.inner.call(name, args, span) } catch (err) { // If binary not registered, try lazy loading if (err instanceof EqException && err.code === 'WacallError') { await this.lazyLoad(name, span) return this.inner.call(name, args, span) } throw err } }

The first time your notebook calls gemm_f64, the inner registry does not have it, so it throws WacallError. The lazy registry catches exactly that error, fetches the binary, registers it, and retries the call. Every subsequent gemm_f64 in the session hits the fast path with no fetch. The loading itself is deduplicated — concurrent calls to the same not-yet-loaded binary share one in-flight promise:

private async lazyLoad(name: string, span: Span): Promise<void> { let pending = this.loading.get(name) if (pending) return pending pending = this.doLoad(name, span) this.loading.set(name, pending) try { await pending } finally { this.loading.delete(name) } }

doLoad resolves which package a binary belongs to (via a binaryName → packageName map), fetches bin/${name}.wasm from the provider, and registers the bytes:

const wasmBytes = await this.provider.fetch(packageName, `bin/${name}.wasm`) await this.inner.registerBinary(name, wasmBytes)

That is the entire pay-for-what-you-use startup model. There is no manifest of "everything" to instantiate up front. A binary comes into existence in the runtime the moment a call demands it.

The provider chain: HTTP, then cache

BinaryProvider is a small interface — fetch, list, exists — with several implementations that stack. HttpBinaryProvider fetches over HTTP from a base URL and works unchanged in a browser, in a Cloudflare Worker, or in Node, because it only assumes a fetch-shaped function. In the web app it fetches from /api/bin/:pkg/*, which streams the object out of an R2 bucket (the server-side half is covered in the compute-heavy app whose server does no computing).

CachingBinaryProvider wraps any upstream provider with a cache backend:

async fetch(packageName: string, binaryPath: string): Promise<ArrayBuffer> { const key = `${packageName}/${binaryPath}` const cached = await this.cache.get(key) if (cached) return cached const data = await this.upstream.fetch(packageName, binaryPath) await this.cache.put(key, data) return data }

The cache backend is pluggable — an in-memory Map for tests, or the browser's Cache API / IndexedDB in production. Because each kernel is a separate object with a stable filename, the browser and the CDN cache them independently. Rebuild the FFT kernel and only fft_f64.wasm gets a new ETag; every other cached binary stays valid. Compare that to one linked module, where changing a single function invalidates the entire download for every user. Granular files give you granular HTTP caching more or less for free.

Manifests describe a package

Binaries are grouped into packages, and each package ships a manifest. A parsed manifest looks like this:

interface PackageManifest { name: string kind: 'wasm' | 'js' | 'script' | 'native' namespace?: string binaries?: readonly string[] nativeVariants?: readonly NativeVariant[] exports?: readonly string[] }

The binaries list is what feeds the lazy registry's binaryName → packageName map: at startup the engine fetches the (small, cacheable) manifests for a handful of core packages and registers every binary name they declare, without fetching a single .wasm byte. Now the registry knows that gemm_f64 lives in blas-kernels — so when a call demands it, doLoad knows exactly which package to pull from. The heavy artifacts stay on the shelf until called.

Per-hardware variants

The interesting part of a manifest is nativeVariants. The same logical function — gemm_f64 — can have several physical builds tuned for different CPUs, and the manifest describes each one:

interface NativeVariant { path: string // e.g. "bin/gemm_avx2_linux_x64.so" os: 'linux' | 'darwin' | 'win32' arch: 'x64' | 'arm64' features: readonly string[] // required CPU features, e.g. ['avx2', 'fma'] priority: number // higher = preferred when several match functions: readonly string[] }

Choosing among them is a pure function of the host's hardware key — its OS, architecture, and the CPU features it actually supports:

export interface HardwareKey { readonly os: 'linux' | 'darwin' | 'win32' readonly arch: 'x64' | 'arm64' readonly features: readonly string[] }

mkHardwareKey(os, arch, features) builds that key — on desktop the feature list comes from real CPU detection; the browser baseline is linux/x64 with the feature set the runtime advertises. selectVariant then does the matching:

export function selectVariant( variants: readonly NativeVariant[], hw: HardwareKey, ): NativeVariant | null { const featureSet = new Set(hw.features) const compatible = variants.filter((v) => { if (v.os !== hw.os || v.arch !== hw.arch) return false return v.features.every((f) => featureSet.has(f)) }) if (compatible.length === 0) return null compatible.sort((a, b) => b.priority - a.priority) return compatible[0] ?? null }

Three steps: keep the variants whose OS and architecture match, drop any that need a CPU feature the host lacks, and among the survivors take the highest priority. A machine with WASM SIMD gets the SIMD build; one with threads gets a variant that requires the threads feature; a bare runtime falls through to the scalar build that lists no required features at all and therefore matches everywhere. The priority ordering encodes the preference — a SIMD-plus-threads variant outranks SIMD-only, which outranks scalar — so the selector always lands on the fastest binary the hardware can actually run, and never picks one that would fault on a missing instruction.

The elegant part is that this is the same key, the same selectVariant, whether the candidate is a native .so on the desktop app or a feature-gated WASM build in the browser. Capability negotiation lives in one place.

The whole flow

Put together, a first-ever gemm_f64 on Float64 data goes:

call gemm_f64 └─ inner registry: not registered → WacallError └─ lazyLoad("gemm_f64") ├─ look up package: blas-kernels ├─ selectVariant(manifest, hardwareKey) → best build for this CPU ├─ provider.fetch → cache miss → GET /api/bin/blas-kernels/bin/gemm_f64.wasm ├─ cache.put(bytes) └─ registerBinary("gemm_f64", bytes) → instantiate └─ retry call → runs in shared WASM memory

Every later gemm_f64 in the session is just the top line. Every later notebook that uses it, across page loads, is a cache hit revalidated with a cheap 304. And a kernel you never call is a file you never download.

Splitting a numerical library this finely is a bet that fetch cost, cache granularity, and independent versioning matter more than whole-program link optimization. For an engine that runs entirely in the browser, that bet pays every time a notebook loads only the four kernels it needs instead of a monolith of four hundred it does not. This is the classic code-splitting argument, pushed all the way down to individual math functions.

Each of those functions actually has more than one implementation behind it — a native build, the WASM binary, and a TypeScript fallback — chosen at call time. That dispatch is the subject of same code, three backends. The notebook is at equana.dev.

Next: Same code, three backends: the TS/WASM/native FFI dispatch chain