Equana

All articles
July 24, 2026·Max Schurigcloudflarearchitecturewebassembly

The compute-heavy app whose server does no computing

Equana runs a whole numerical stack on Cloudflare Workers, and the Worker never runs a single floating-point kernel. Here is the inverted architecture and why it wins.

Equana is a scientific-computing notebook. You open it in a browser, type matrix algebra and FFTs and sparse solves, and it runs them fast. It is hosted on Cloudflare Workers — a platform whose whole pitch is tiny, fast, stateless request handlers with strict CPU limits. That pairing sounds wrong. A notebook that factors 4000×4000 matrices, running on an edge runtime that measures CPU budget in milliseconds?

It works because the Worker never does any of the math. Not a single BLAS call, not one FFT, not one element-wise exp. Every floating-point operation in Equana happens on the machine in front of you, inside WebAssembly. The server's job is the boring half of an app — who are you, and where is your data. This post is about that inversion: why the compute lives entirely on the client, what that leaves for Cloudflare to do, and why it turns several hard hosting problems into non-problems.

The Worker's actual job

Here is the entire server responsibility, taken from the deployed wrangler bindings:

  • D1 (DB) — the SQLite database: accounts, notebook metadata, sharing.
  • KV (SESSIONS_KV) — session storage for sign-in.
  • R2 (SCRIPTS_BUCKET, PACKAGES_BUCKET) — object storage for saved scripts and for the WASM kernel packages.

The request handler itself is a React Router server-side render wrapped in Sentry. It authenticates the request, reads and writes rows, streams HTML, and adds a couple of response headers. That is the extent of it. There is no /api/compute endpoint, because there is nothing to compute server-side. The numerical engine — a TypeScript AST-walking interpreter driving WebAssembly kernels over a single shared linear memory — is shipped to the browser as static assets and runs in a Web Worker on the user's own CPU.

Once you internalize "the server does no math," the Cloudflare CPU limit stops being a constraint and becomes irrelevant. A 4000×4000 matrix multiply that takes 300 ms of dense floating-point work never touches the edge. It runs on the user's cores.

Cold starts you can stop worrying about

Serverless people spend a lot of energy on cold starts, because for a compute API the cold start is dead time before the real work begins. Equana's Worker does no real work of that kind, so its cold start is only ever "parse the request, look up a session, render a route, hit D1." Cloudflare's isolate model already makes that fast. The heavy engine — hundreds of kilobytes of WASM, the interpreter, the kernels — has a warmup too, but it happens once in the browser, on first notebook load, and then stays resident in a Web Worker for the whole session. It is entirely off the request path.

So there are two independent warmups and neither hurts. The server warmup is trivial because the server is trivial. The engine warmup is client-side and one-time. A returning user with a warm HTTP cache and a warm Worker isolate is computing at full native-ish speed with no server round-trip in the loop at all — because the loop has no server in it.

Kernels live in R2, selected per machine

The one server responsibility that is specific to a numerical app is delivery of the WASM kernels, and even that is storage, not compute. Kernels are stored as objects in the PACKAGES_BUCKET R2 bucket and served by one route, /api/bin/:pkg/*:

GET /api/bin/blas-kernels/manifest.json GET /api/bin/blas-kernels/bin/gemm_f64.wasm GET /api/bin/blas-kernels/bin/axpy_f64_avx2_linux_x64.so

The route reads the object out of R2, sets a content type, and hands the body straight back. It does apply one small piece of judgement — cache policy. Portable .wasm binaries keep stable filenames across rebuilds, so they are served with a short freshness window plus ETag revalidation (public, max-age=300, must-revalidate), which lets a bug-fixed kernel propagate to every edge and browser within minutes at the cost of cheap 304 Not Modified responses. Native shared libraries (.so/.dylib/.dll) encode OS, architecture, and CPU features in their names — they are effectively content-addressed — so they get immutable and a one-year TTL.

Which kernels a given client fetches depends on the client's hardware. The manifest lists variants tagged with os, arch, required CPU features, and a priority, and the engine picks the best match at load time. That per-hardware selection — and the reason each function is its own lazily-fetched .wasm file — is its own story, in one .wasm file per function. What matters here is where the decision runs: on the client. The Worker just returns whichever object was asked for.

Cross-origin isolation: the one header dance

The engine keeps all numerical data in a single SharedArrayBuffer so that the interpreter, the kernels, and multiple Web Workers all address the same memory with zero copying (the mechanics are in zero-copy WASM via SharedArrayBuffer). Since Spectre, browsers only hand you a SharedArrayBuffer on a cross-origin isolated page, and that isolation is granted through two response headers.

This is the one genuinely numerical-app-specific thing the Worker does, and it is still just header-setting. Every response gets:

Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: credentialless

credentialless COEP is deliberate: it enables cross-origin isolation while letting the app pull module workers and subresources without requiring a Cross-Origin-Resource-Policy header on every single asset, which the older require-corp mode demanded. One route is carved out — /checkout sets both headers to unsafe-none so the payment provider's script can load from its CDN, at the cost of that page not being cross-origin isolated (it does no computing, so it does not need SharedArrayBuffer). Setting two headers is the entire server-side contribution to making high-throughput parallel numerics possible in the tab.

Your data does not leave the device

The most valuable consequence of pushing all compute to the client is not performance — it is that the data never has to travel. When you load a CSV, run a regression, and plot residuals, the numbers live in that browser-side SharedArrayBuffer and get crunched by WASM in your own Web Worker. They are never marshalled into a request body and shipped to a compute server, because there is no compute server. The only bytes that reach Cloudflare are the ones you explicitly choose to save — a notebook you persist lands as text in R2 or as metadata in D1, and that is a deliberate save action, not a side effect of running a cell.

For anyone doing analysis on data they would rather not upload — clinical measurements, proprietary experiments, financial series — this is a structural guarantee, not a policy promise. A server that never receives your matrices cannot leak, log, or subpoena them. The architecture that lets a stateless edge runtime host a heavy numerical engine is the same architecture that keeps the numbers on your machine.

What the inversion costs

Pushing compute to the client is not free of trade-offs, and it is worth naming them plainly. The user's hardware sets the ceiling — a low-end laptop computes like a low-end laptop, and there is no fleet of beefy server GPUs to fall back on. First load pays to fetch the interpreter and the initial kernels, though lazy per-function loading keeps that bounded to what the notebook actually uses. And cross-origin isolation is a constraint you carry forever: every embed, script, and third-party asset has to cooperate, which you discover one broken resource at a time.

Against that: no compute tier to scale, autoscale, or pay for; a server whose cost is proportional to page views rather than FLOPS; cold starts that do not matter; and a privacy model that falls out of the design instead of being bolted on. For a numerical notebook, that trade has been decisively worth it.

The engine that does all this work runs in your browser at equana.dev. If you want the layer below this one — how kernels are split into per-function binaries and fetched on demand — read one .wasm file per function, and for the shared-memory core that makes browser numerics fast, zero-copy WASM via SharedArrayBuffer.

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