capnwasm vs capnweb
Where capnwasm wins vs capnweb vs REST/JSON, where it loses, where it’s tied. Bundle and fixture-wire rows load build-generated numbers from the current assets; runtime rows are benchmark snapshots with links to the live benches.
Where capnwasm wins
| workload | capnwasm | capnweb | REST/JSON | win |
|---|---|---|---|---|
| Burst 1000 calls (per-call) | measuring… | measuring… | n/a | live |
| 64 KB text echo round-trip | measuring… | measuring… | n/a | live |
| 4 KB text echo | measuring… | measuring… | n/a | live |
| Single tiny call | measuring… | measuring… | n/a | live |
| Wire bytes for 4 KB binary blob (gzip) | loading… | loading… | loading… | build metric |
| Sparse field access (read 3 of 32) | measuring… | measuring… | — | live |
capnwasm consistently beats capnweb on:
- Throughput. Once you batch calls (which most apps do), capnwasm pulls 3× ahead.
- Big payloads. Binary wire skips the base64-in-JSON tax entirely.
- Wire size. For binary data of any kind, capnwasm sends the bytes; capnweb base64-encodes.
Where capnwasm loses
These are real, not handwave-able-away.
1. Bundle size: ~2× larger on the wire (apples-to-apples brotli)
| gzip (Workers deploy limit) | brotli (browsers / Cloudflare Pages) | |
|---|---|---|
| capnweb (everything) | loading… | loading… |
| capnwasm wasm-only (decode capnp messages) | loading… | loading… |
| capnwasm + RPC | loading… | loading… |
| capnwasm typed + http-batch (typical browser app) | loading… | loading… |
Loading build metrics…
If your bundle budget is tight and you don't need binary wire interop, capnweb is the smaller choice. There is no way for capnwasm to reach 21 KB while still doing RPC; the wasm runtime is what enables the rest of the wins.
2. Cold start: now essentially tied in Node, still slower in the browser
| import | load (compile + link) | total | |
|---|---|---|---|
| capnweb | 1.5 ms | 0.4 ms | 1.9 ms |
| capnwasm (inlined) | 1.0 ms | 0.8 ms | 1.8 ms |
| capnwasm (slim, separate wasm) | 0.4 ms | 0.6 ms | 1.0 ms |
Numbers are fresh-process Node 22, average of 8 runs. In an earlier release capnwasm's init was ~11 ms because the loader did instanceof Response, which lazy-initializes Node's undici fetch implementation. The fix is duck-typing: never reach for Response unless the source is clearly a URL.
In a browser the picture is different. With an empty HTTP cache the first visit pays network + streaming compile; the landing page measures the current browser/host directly. Warm reloads (V8 code-cache hit) drop close to the measurement floor. capnweb's smaller JS parses quickly either way. So on a fresh tab capnwasm is still measurably slower because the wasm bytes are larger; once the bundle is cached the gap is small.
3. Schema friction
capnwasm requires a Cap'n Proto schema — even when generating a TypeScript-only client you go through npx capnwasm gen. capnweb works on arbitrary JS values: you call methods, JSON-shaped data goes over the wire, no schema step.
If you control both ends and never want to define a schema, capnweb is friction-free. If you want types, IDE completion, wire-format stability across versions, and interop with C++/Rust/Go peers, the schema is the price you pay for that.
4. Cap-passing micro-overhead
| capnwasm | capnweb | |
|---|---|---|
getChild → call round-trip | measuring… | measuring… |
Roughly tied — capnwasm is ~5% slower on the cap-passing fast path. Doesn't matter unless you're doing very deep cap chains; both are dominated by network RTT in any real deployment.
5. Single-call latency over real network: invisible
The 8.5 μs vs 14 μs gap on tiny calls only matters if the network adds < 5 μs of latency, which essentially never happens. Over a real WebSocket on the same continent (typically 5–50 ms RTT), you cannot tell capnwasm and capnweb apart on a single tiny call.
The win shows up in bursts (parallel calls) and payload size (decode/encode work).
Where the in-browser playground says they're tied
The live playground on this site fetches N records as same-origin static assets and renders them. When network cost is near-free, the three protocols come out within a few percent of each other:
| workload | REST | capnweb | capnwasm | capnp wire (gz) |
|---|---|---|---|---|
| 200 records, 32 B avatars | 107 ms | 108 ms | 110 ms | 26.6 KB |
| 50 records, 4 KB avatars | 15 ms | 15 ms | 15 ms | 205 KB (vs 272 KB JSON) |
The bytes-on-wire savings only translate into faster end-to-end times once the network has any meaningful latency. In a same-origin static-fixture bench, fetch overhead dominates everything else — capnwasm's decoder is fast, but it can't pay back the wasm load + per-record boundary cost when network is essentially free. Add real latency between you and your backend and the bytes savings start to compound.
Where the playground can't show capnwasm winning yet: an RPC workload over WebSocket with bursts, capability passing, or 64 KB binary payloads. That's a follow-up bench page.
Where REST/JSON loses to both
For completeness — neither library is competing with raw fetch() on the protocol axis, but it's worth showing what JSON-without-RPC costs:
- No type safety without runtime validation (zod/ajv etc).
- No bidirectional streaming without inventing your own protocol.
- No capability passing — every request needs an auth token.
- No promise pipelining — sequential awaits cost you N round-trips.
- Bytes: 64 KB binary becomes ~88 KB after base64 + JSON escaping.
If your app fits inside “fetch some data, render it, occasionally POST” then plain REST is fine and probably what you should use. Both capnweb and capnwasm are for apps that need RPC semantics — capability-secure objects, pipelining, streaming, wire-compatible types.
When to choose what
Choose REST/JSON when:
- Your app is request/response only, no real-time state.
- Your team knows REST and the docs/tooling matter more than the wire.
- You're integrating with a third-party API that publishes OpenAPI/Swagger.
Choose capnweb when:
- Pure JS-to-JS, all-text-shaped payloads.
- You want the smallest possible bundle (21 KB gz).
- You don't need wire interop with non-JS peers.
- Schema friction is a deal-breaker.
Choose capnwasm when:
- You're moving binary data (images, audio, ML models, embeddings).
- You sustain many concurrent calls (capnwasm pulls 3× ahead under burst).
- Payloads are routinely > 1 KB (binary wire crushes JSON for any non-trivial size).
- You want one schema language and one codegen toolchain for both internal and third-party APIs.
- You need wire compatibility with C++/Rust/Go services.
- You can absorb the 23 KB extra bundle and 5–50 ms first-load cost.
End-to-end with rendering
Per-call latency is one slice of the truth. The full picture is what happens from
cap.method() through wire, decode, every .field read used by render, to
forced layout. We built the
live render bench to measure exactly that across
capnweb × capnwasm × (WebSocket, HTTP-batch) × (small, medium, large) × (cold, warm).
Every cell. No averages.
Snapshot from Apple Silicon, Chromium prod build, same-origin local run (warm medians, 10 iter). For current numbers, use the live render bench:
capnwasm wins (warm medians)
| workload | capnwasm/WS | capnweb/WS | note |
|---|---|---|---|
| Binary blob 256 KB round-trip | 1.0 ms | 2.3 ms | 2.3× faster — raw bytes vs base64 |
| Sparse field access (50× calls, read 3 of 32) | 2.6 ms | 3.2 ms | only crosses wasm 3× per call, capnweb materializes all 32 |
| List render 100 records | 1.2 ms | 1.4 ms | schema-typed binary decode beats JSON.parse + DOM |
capnweb wins (warm medians)
| workload | capnweb/WS | capnwasm/WS | note |
|---|---|---|---|
| Re-read storm (1000× field reads after one fetch) | 200 μs | 400 μs | 2× faster — pure JS reads vs per-read wasm crossing |
| List render 1000 records | 8.9 ms | 9.7 ms | DOM dominates; capnweb's up-front parse is “free” here |
Practically tied (warm medians)
| workload | capnwasm/WS | capnweb/WS | note |
|---|---|---|---|
| Dense field access (50× calls, read all 32) | 14.9 ms | 13.8 ms | within noise; capnwasm crosses wasm 32× per call, capnweb's eager-decode catches up |
| Re-read 10× | 200 μs | 300 μs | both at the measurement floor on a hot WS |
HTTP-batch numbers generally trail WS for both libraries because each request reopens / dispatches a session. The cold path for HTTP is shorter than WS (no handshake), but the warm path is longer (no kept connection). capnwasm holds the lead on blobs and sparse reads even over HTTP; capnweb keeps its re-read advantage on either transport.
Cold paths are all over the place for sub-100-ms workloads. The first call after opening a transport mixes WS handshake / first POST / wasm fetch (capnwasm only) / V8 JIT warmup, none of which is the library's fault. Look at warm.
The honest takeaway: pick capnwasm if your workload is binary-heavy, sparse-field, or you want one schema across languages. Pick capnweb if your workload is JS-only and dominated by repeated reads of small payloads. The render bench is a click away — don't take our word for it.
Methodology / reproducibility
- In-process numbers:
node bench/rpc_bench.mjsandnode bench/realistic.mjs. - Both use a paired in-memory transport, same workload runner.
- “vs capnweb” assumes capnweb is checked out at
../capnweb(sibling repo). - All wins reported are median of multiple runs; outliers from GC pauses excluded.
- Bundle and fixture-wire rows are build-time metrics loaded from
/metrics/build.json, generated byweb/scripts/generate-fixtures.mjsfrom the currentdist/assets andweb/node_modules/capnweb/dist/index.js. Runtime benchmark rows are snapshots; run the linked live benches for current browser/server numbers. - In-browser numbers:
pnpm dev, then run the playground in Chromium against Wrangler. - End-to-end render bench:
pnpm dev, then visit/render-benchand click Run. Source atweb/render-bench.html+web/src/render-bench/main.ts; Worker endpoints are insrc/worker.mjs.