ZenZip logozenzip

Benchmarks

ZenZip measured the NAPI boundary, handler dispatch, HTTP, and SQLite before writing engine code. These numbers shaped — and killed — design decisions.

Read the caveat first

All numbers from a thermally-limited laptop (i5-1135G7, Windows 10, Node 23.6, Rust 1.95) with same-machine load generation. They are relative signals for architecture decisions, not marketing claims. Server-grade re-runs are tracked as a follow-up.

Why benchmark first#

ZenZip embeds Rust in Node, so every design hinges on one question: what does crossing the boundary cost? ZenZip ran four benchmarks before writing a line of engine code. The result is a set of hard rules:

// Rules the engine is built on (all spike-derived, all enforced):
//
// 1. Hot-path JS→Rust calls are SYNC.
//    push / trigger / emit / recordStep — 14–34 ns each.
//
// 2. Async NAPI functions are BANNED on hot paths (~85 µs each).
//    Only cold calls: stop(), waitForRun().
//
// 3. Rust→JS handler dispatch is PIPELINED ThreadsafeFunctions.
//    36 µs alone → 2.4 µs amortized with 256 in flight.
//
// 4. The workflow journal is BATCH-PREFETCHED per attempt —
//    step memoization hits never cross the boundary at all.

JS ↔ Rust boundary#

CallCost
sync noop()13.9 ns
sync add(a, b)33.7 ns
sync echo 1KB buffer151 ns (no copy)
sync echo 64KB buffer165 ns (size-independent)
async add(a, b)85.6 µs — ~2,500× the sync cost

The async row is the headline: tokio dispatch + promise resolution + event-loop wakeup make every async NAPI call cost what ~2,500 sync calls do. That single measurement dictated the entire boundary protocol.

Handler dispatch — Rust → JS#

100,000 round-trips: Rust calls a JS callback and awaits the returned value (the shape of every queue/workflow handler invocation):

PipeliningThroughputAmortized cost
1 in flight27,400/s36 µs
×16291,600/s3.4 µs
×64351,200/s2.8 µs
×256408,600/s2.4 µs

Pipelining hides the wakeup cost almost entirely — and a queue/workflow runtime is naturally pipelined (many jobs and runs in flight). At 400k dispatches/s, the boundary will never be the bottleneck; storage and your own handler code saturate far earlier.

The HTTP no-go#

The original idea included a Rust HTTP server (“faster than Fastify”). We benchmarked hello-world with the load generator in a separate process:

Serverreq/sp99
Express 55,200–6,20040–57 ms
Fastify19,80014 ms
hyper (pure Rust)26,80010 ms
hyper → JS handler23,1009 ms
  • hyper with real JS handlers: only 1.09–1.17× Fastify across stable runs — far below the 1.3× bar we set for justifying the complexity.
  • Everything beats Express 4–5× — including plain Fastify. “Faster than Express” is table stakes, not a differentiator.
  • Verdict: NO-GO. HTTP stays a Node adapter; the framework leads with durability, never router benchmarks. Killing our own feature with our own benchmark is the point of measuring first.

Feature-for-feature: vs Express & Fastify#

Early benchmarks killed the Rust HTTP server idea — ZenZip's HTTP is a thin node:httpadapter, not a speed play. So the fair question isn't “is it the fastest router” but “what does the adapter cost you against the two frameworks people actually migrate from?” Identical handlers, same machine, load gen in a child process, best-of 3 interleaved rounds:

ScenarioExpress 5FastifyZenZipvs Expressvs Fastify
GET /6,34417,55415,7492.48×0.90×
GET /json6,14416,52715,0022.44×0.91×
GET /users/:id5,97716,60016,2162.71×0.98×
POST /echo4,7248,7878,4411.79×0.96×
GET /mw + CORS6,26616,61513,9322.22×0.84×

Numbers are req/s, best-of. Read-out:

  • ZenZip is 1.8–2.7× Express on every scenario and now within 0.84–0.98× of Fastify — effectively even on routing and param extraction (0.98×), a hair behind on the middleware chain.
  • What closed the early gap: the adapter used to parse the request body on every request, so a body-less GET still paid for an await over the request stream (an extra event-loop turn each). Skipping the read for GET/HEAD/OPTIONS and declared-empty bodies, plus reusing the parsed URL instead of re-parsing it for ctx.query, lifted GET throughput 50–130%.
  • The router is now a radix trie— O(path depth) match with no per-request route-array allocation, replacing the old linear scan. On this 5-route microbench it's within noise of the linear version; the win grows with route count, and it closed the routing/param scenarios to ~1.0× Fastify.
  • What we tried and reverted:swapping each request/response onto a shared prototype (Fastify's trick) to kill per-request closure allocation. Measured: it halved GET throughput. Per-request Object.setPrototypeOfis a V8 deopt that costs far more than the closures it removes. Plain closures stay — a benchmark killing our own “optimization” is the point of measuring. See the roadmap.
  • Takeaway:the HTTP layer is on par with Fastify and far ahead of Express — but you don't adopt ZenZip for router req/s, you adopt it for durable queues, workflows, and agents Express and Fastify don't have. The adapter being this competitive means migrating costs you nothing on the request path.
# reproduce — all three frameworks, identical handlers
cd bench && node compare.mjs
DURATION=5 ROUNDS=3 CONNECTIONS=64 node compare.mjs

SQLite throughput#

OperationThroughput
Insert (1,000/transaction)209,600 jobs/s
Claim + ack (1 job/claim, 2 commits/job — deliberate worst case)9,800 jobs/s

Worst-case single-job claiming lands at ~10k/s on a laptop with zero optimization; the shipped engine claims in batches of 32. SQLite WAL comfortably covers the embedded, single-node target — Postgres exists for the multi-node story, not for throughput.

Postgres at scale#

Two changes lift the multi-node scale ceiling, both measured against a live Postgres rather than asserted:

  • Partitioned event outbox. The events table is RANGE-partitioned by emitted_at into fixed one-day buckets. Retention GC now drops a whole aged partition — an instant DROP TABLE — instead of a row-by-row DELETE that scans and bloats at scale; a DEFAULT partition catches any un-bucketed row so correctness never depends on partition upkeep. Per-partition indexes also stay small. The current and next partitions are pre-created at startup and before each sweep.
  • Priority-ordered dequeue index. A partial index (queue, priority DESC, id) WHERE status = 0 lets the claim walk ready jobs in dispatch order and stop at the batch LIMIT — no sort. EXPLAIN ANALYZE on an 80,000-row queue:
Claim of 32 jobs (80k-row queue)PlanTime
BeforeBitmap scan of 20k rows → Sort (priority, id)53.5 ms
AfterIndex Scan on idx_jobs_ready, no sort0.83 ms

~60× on the hot claim path once a queue's backlog is deep enough to matter. Both changes are Postgres-only — the embedded SQLite path is single-node and already fast (above); partitioning exists for the multi-node scale story, exactly like Postgres itself.

Methodology notes#

  • Load gen in a child process.The first HTTP run had autocannon sharing the server's event loop and overstated the Rust ratio by ~2×. Caught, fixed, kept as a rule.
  • Thermal throttling is real. Sequential bench order penalized later targets (one server swung 12.7k→26.8k req/s between runs). The harness now runs interleaved rounds and reports best-of per target.
  • Reproducible. Every benchmark lives in bench/ in the repo: pnpm bench:boundary · bench:tsfn · bench:http · bench:sqlite · bench:compare.