WASM-native proving for ZK SDKs: an SDK author's take
Why zera-sdk ships native Rust on Node and snarkjs in the browser — and what it would actually cost to ship a WASM-compiled Rust prover for the browser path. A design post about the dual-target build pipeline.
- FROM
- Dax the Dev <[email protected]>
- SOURCE
- https://blog.skill-issue.dev/blog/wasm_native_proving_sdk_authors_take/
- FILED
- 2026-05-03 19:00 UTC
- REVISED
- 2026-05-03 19:00 UTC
- TIME
- 9 min read
- SERIES
- Building the Zera SDK
- TAGS
The most-asked engineering question on every ZK SDK call I take is some shape of:
“Why are you using snarkjs in the browser when you have a Rust core?”
The honest answer is that we made a decision in March 2026, captured it in RFC 001 under the heading “notes are too sensitive to round-trip through WASM”, and have been quietly re-evaluating it ever since. The dishonest answer is that we shipped what was working. Both answers contain something true. This post is the long version that fits neither into a Twitter thread nor into the RFC.
The shape of the problem is: the Rust core exists, it’s faster than snarkjs, and yet for the browser path we ship snarkjs. Why? And what would it actually cost to swap?
The dual-target shape
Every ZK SDK in 2026 has the same engineering shape, even if its authors don’t admit it:
flowchart TB C[zera-core - Rust] --> N[neon-rs - native node bindings] C --> W[wasm-pack target] N --> NJ[zera-sdk on Node and Electron] W --> WB[Browser path - planned] C2[circuits .circom] --> SNK[snarkjs WASM prover] C2 --> ARK[arkworks-circom Rust prover] ARK --> N ARK --> W SNK --> WB2[Browser path - shipped today] classDef ship fill:#0a4014,stroke:#4ade80,color:#fff classDef plan fill:#3a2a0a,stroke:#facc15,color:#fff class NJ,WB2 ship class WB plan
There is a Rust core that does crypto primitives (Poseidon, Merkle, nullifiers, note construction). The Rust core compiles two ways:
- Native, via
neon-rs, into a Node.js addon that ships zero-copy across the Buffer ABI. - WebAssembly, via
wasm-pack/wasm-bindgen, for browser environments.
There is also a prover — a separate concern from the crypto primitives — that takes a circuit’s R1CS plus a witness plus a zkey and produces a proof. The prover is structurally separate from the core and ships as one of:
- snarkjs, a JavaScript prover with a hand-tuned WASM bigint inside it. Browser-native, mature.
- arkworks-circom, a Rust prover that consumes the same R1CS and zkey, compiled either native (server) or WASM (browser).
ZERA today ships option 1 of the core via neon-rs (native) and option 1 of the prover (snarkjs) in the browser. The path that doesn’t exist is the Rust prover compiled to WASM. That’s the gap this post is about.
Why we deferred the WASM prover
Three reasons, in honest order of how much each weighed:
1. The marshalling cost of crypto-primitive calls is real
When the SDK computes a Poseidon commitment, it calls zera-core from TypeScript. Through neon-rs, that call is zero-copy: the JS Buffer holding the note bytes is a pointer the Rust side reads directly. Through wasm-bindgen, the same call requires copying the bytes into the WASM linear memory, calling the function, and copying the result back. For a 32-byte input and a 32-byte output that’s tens of microseconds — negligible per call, real when you’re hashing 32 Merkle nodes per proof.
Measured numbers, on a 2024 MacBook Air M3, hashing one BN254 Poseidon node:
| Path | Cost | Notes |
|---|---|---|
zera-core via neon-rs | ~12 µs | Native Rust, zero-copy |
circomlibjs Poseidon | ~280 µs | Pure JS BigInt |
zera-core via wasm-bindgen | ~85 µs | Marshalling dominates |
zera-core via wasm-bindgen, batched 32 | ~430 µs (= ~13 µs/hash) | Marshalling amortises |
The batched WASM call is competitive with the native path because the marshalling overhead is paid once per batch and not once per hash. That’s the engineering punch line: WASM-from-Rust is fine if you design the API around batched calls, and bad if you ship a one-call-per-primitive ergonomic API. snarkjs gets this right by accident — its internals are batched because they’re polynomial-time, not constraint-time. A naive port of neon-rs’s API surface to WASM would lose performance vs the native path while also losing performance vs snarkjs, because it would batch neither.
2. The wasm-bindgen-rayon deployment story is fragile
Multi-threaded Rust in the browser depends on the wasm-bindgen-rayon adapter, which depends on SharedArrayBuffer, which depends on the cross-origin isolation headers Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp being served by your CDN. Without those headers, the WASM prover runs single-threaded, at which point it loses to snarkjs because snarkjs is allowed to use Web Workers from JavaScript directly without needing isolation.
That’s not theoretical. Several wallet integration partners we’ve talked to embed our SDK inside an iframe on third-party sites where they don’t control the headers. snarkjs works there. wasm-bindgen-rayon does not. Until the embedding situation improves — Worker threads as a first-class WASM feature, ideally via the wasi-threads proposal — the deployment surface for a Rust-WASM prover is narrower than the deployment surface for snarkjs, even if the prover itself is faster on the supported subset.
3. snarkjs, today, is good enough for the circuits we ship
This is the part the cypherpunk in me hates and the shipping engineer in me has made peace with. The circuits inside zera-sdk — deposit, transfer, withdraw — are in the 5,000–25,000 constraint range. snarkjs proves them in 1–4 seconds in the browser, threads on, IndexedDB-cached zkey. That’s slow enough to need a loading state and fast enough that users don’t bail. (Numbers from Proving in the browser, by the numbers.)
The arkworks-WASM prover would prove the same circuits in 0.5–1.2 seconds — a 3–5× win. That’s a real win and not a transformative one. Transformative would be folding (Nova, SuperNova, ProtoStar) for batch operations, or a small-field STARK migration for the substrate. The marginal-cost calculation said: ship snarkjs, queue arkworks-WASM, prioritise folding for the v2 batch flow.
What the WASM prover would actually cost
Concretely, if I were spec’ing the work:
| Task | Estimate | Risk |
|---|---|---|
Vendor arkworks-circom and pin to a known-good commit | 2 days | Low |
Build it for the wasm32-unknown-unknown target with wasm-bindgen-rayon | 3 days | Low |
| Add COOP/COEP headers to the SDK reference deployment | 1 day | Low |
| API parity with the snarkjs path (proof format, zkey loader) | 5 days | Medium — proof byte-format differences exist |
| Browser benchmark suite + regression tests | 5 days | Medium |
| Iframe-fallback path that auto-degrades to snarkjs without isolation | 5 days | High — this is the actual hard part |
| Documentation, partner integration guides | 5 days | Medium |
| Total | ~5 person-weeks | The fallback path is the load-bearing risk |
The genuinely hard part isn’t compiling the prover. It’s the fallback path. We can’t ship a browser SDK that breaks on every embedded iframe deployment. So the SDK has to detect at runtime whether SharedArrayBuffer is available, and silently fall back to snarkjs if it isn’t. That dual-prover fallback path adds maintenance overhead — two provers, two zkey loaders, two test matrices — that doesn’t exist today.
This is the calculation that keeps coming out the same way: 5 person-weeks for a 3–5× speedup on the supported subset, plus permanent dual-prover maintenance, vs. shipping the same code budget on folding for batch ops or on Solana-side STARK readiness. The folding work has more upside; the STARK work has more strategic value. The WASM prover work has the most concrete win for the current shape of usage.
We’re going to ship the WASM prover. It’s on the v0.5 milestone. But it’s been on a milestone for two quarters now, and the reason it keeps slipping is that every quarter the alternative work has bigger expected value.
The four-way SDK-author tradeoff
| Option | Cost | Latency | Blast radius | Notes |
|---|---|---|---|---|
| snarkjs (browser) + native Rust (Node) | Two provers; two test matrices; some duplication | Fast on Node; acceptable in browser; deploys anywhere | snarkjs is mature; native path is mature; the seam between is small | What zera-sdk ships today; the pragmatic 2026 default |
| arkworks-WASM (browser) + native Rust (Node) | Single prover codebase; needs COOP/COEP headers in browser | 3-5x faster in browser; same on Node | Iframe and third-party embedding paths break without isolation | Where I'd ship a v2 if I had a quarter to invest |
| wasm-bindgen-rayon (browser) + native Rust (Node) | Same as above; explicit threading surface | Same as above | Same as above; same isolation requirement | Effectively a flavour of the arkworks-WASM choice |
| OffchainLabs nitro-prover style — proof off-chain server | Server-side proving; client just submits | Variable; depends on server capacity | Centralisation surface; server compromise = wallet compromise (for transactions) | Wrong threat model for a privacy SDK; right for some L2 use cases |
What changed my mind in 2026
Two things, both external:
Header support went mainstream. Every major hosting provider — Vercel, Netlify, Cloudflare Pages — now ships COOP/COEP header configuration as a first-class feature. In 2024 you had to write custom worker code to inject the headers; in 2026 it’s a checkbox. That moves the “fallback path complexity” from load-bearing to secondary risk.
The Mopro / zkMopro project published clean comparison numbers. Their Circom prover comparison gives a third-party benchmark that I can point partners at when I’m justifying a 3–5× speedup. Internal benchmarks are also useful, but the question changes when there’s external corroboration.
The combination of these two means the case for shipping the WASM prover is meaningfully stronger in mid-2026 than it was in early 2026. I’d put 70% confidence that we ship arkworks-WASM in the browser path before the end of 2026, and that the snarkjs fallback survives as a secondary path indefinitely.
A note on the bigger architectural question
The deeper question — the one I think about more often than I write about — is whether the crypto-primitives core and the prover should even be the same artefact. They’re not in zera-sdk: zera-core is the primitives, snarkjs is the prover, they don’t share code. That separation has been quietly excellent for shipping velocity.
What we don’t do is share the same separation in our partner SDKs. Several integrators have asked: “can I use zera-core for the primitives but a different prover for my circuit?” The answer today is yes, with caveats — the witness format has to match, the zkey has to be Groth16-over-BN254, and the on-chain verifier has to accept the resulting proof. In practice nobody has done this yet. But the architectural shape supports it, and if a partner wanted to ship a Halo2 verifier on Solana (when that’s possible), they could keep using zera-core’s primitives and swap the prover wholesale.
This is the right shape, in retrospect. The crypto core is small, well-tested, audited. The prover is big, fast-moving, swappable. Conflating them — as some early SDKs do — bakes the prover choice into every wallet integration, and makes the prover migration ZERA is currently considering much more painful than it needs to be.
What I’d ship differently for v0.5
Three concrete deliverables, in order of how much they’d actually move the needle:
@zera-labs/sdk-prover-wasm— a separate npm package containing arkworks-circom compiled to WASM withwasm-bindgen-rayon. Opt-in via a constructor flag; falls back to snarkjs on unsupported platforms. This is the work I described above; the new shape is to ship it as a separate package so existing integrators don’t pull a 4 MB WASM blob unless they want it.- MCP-side prover-selection tool. The
@zera-labs/mcp-servercurrently uses snarkjs unconditionally. An MCP-level configuration for “prefer the fastest available prover” would let agents tune for batch operations vs. one-shot transactions. More upside than it sounds. - A shared zkey-loader abstraction. Today the SDK reads zkeys from URLs; the MCP server reads them from disk; the test harness reads them from a fixtures directory. A
ZkeyLoadertrait — backed by URL, IndexedDB, fs, or arbitrary user code — would unify the three paths and unblock a “user provides their own zkey” advanced flow that several research partners have asked for.
Further reading
- RFC 001: zera-sdk monorepo shape — the design doc this post discusses
Dax911/zera-sdk— the SDK itself- Mopro: comparison of Circom provers — the external benchmark that changed my prior on the WASM-prover decision
iden3/snarkjs— what we ship in the browser todaywasm-bindgen-rayon— what we’d use for the Rust-WASM prover path- Proving in the browser, by the numbers — the prover-time data that informed this post
- The MCP server inside zera-sdk — the third audience this SDK serves
- Building the Zera SDK: Day One — the day-one architecture