A small, reproducible benchmark for the question raised in kyushu#99: before evaluating any engine, what does a compute-bound handler actually cost?
It runs one handler kernel through two engines and reports size, cold start, and throughput:
- QuickJS-in-WASM —
quickjs-emscripten, the same engine family Kyushu runs today viawasm-rquickjs. The handler JS is parsed and interpreted inside the WASM VM. - jz-AOT-WASM —
jzcompiles the same handler JS ahead-of-time to a standalone WASM module. No interpreter.
The kernel (kernels/resize.js) is a bilinear RGBA image
resize — a representative pure-compute handler. Valid jz is valid JS, so the
exact same source file is what both engines run.
npm install
npm run benchWorkload: bilinear resize 1920×1080 → 320×180 (0.058 Mpx/op)
Cross-check: both engines produce checksum 36665856 ✓
QuickJS-in-WASM jz-AOT-WASM jz advantage
────────────────────────────────────────────────────────────────────────
module / handler size 506.7 KB engine 1.9 KB 265× smaller
cold start 21.99 ms 0.20 ms 112.41× faster
throughput 8/s 781/s 101.27× faster
per resize 129.60 ms 1.28 ms 45 Mpx/s
────────────────────────────────────────────────────────────────────────
Numbers vary by machine; the shape is the point. For a tight numeric loop, an AOT-compiled WASM module runs ~100× faster than the same code interpreted, and ships as ~2 KB next to a ~500 KB engine.
- Same source, one variable. Both engines execute
kernels/resize.jsverbatim. The jz path compiles it; the QuickJS path strips ESM syntax (so top-level bindings become VM globals) and interprets it. Nothing else differs. - Correctness gate. Both engines must produce a bit-identical output checksum or the bench exits non-zero. The performance numbers only print when the two agree — so this is also a conformance check of jz against QuickJS.
- Cold start is per-worker spin-up: jz instantiates a precompiled module (Kyushu would AOT-compile once at freeze-time, not per request); QuickJS creates a context and parses the handler source.
- Throughput times steady-state
resize()calls over a 1.5 s window. QuickJS calls are driven 8-per-crossing so the host↔VM boundary cost is amortized, not measured — this flatters QuickJS slightly, on purpose, to keep the comparison about execution, not marshaling.
This is an engine-level comparison, not a drop-in Kyushu evaluation:
- It is not a full Kyushu worker. It does not reproduce the template-freeze, routing/fetch/async glue, or Kyushu's Node.js polyfill surface. It isolates the one thing in question: the cost of running compute-bound handler code.
- jz is not a QuickJS replacement, and isn't trying to be. jz explicitly does not target async/I-O or Node polyfills. The npm/polyfill familiarity that makes Kyushu pleasant stays in the QuickJS worker. jz only earns its keep on the pure-numeric inner loop — image/audio/DSP/parsing/hashing. For I/O-bound handlers it offers nothing here.
quickjs-emscripten≠wasm-rquickjs. Same engine family (QuickJS), but a different WASM build than Kyushu's. Treat the QuickJS column as representative, not as Kyushu's exact runtime.- QuickJS can precompile to bytecode to shave parse time off cold start; the
default path here parses source, which is what
wasm-rquickjsdoes too.
The intended deployment isn't "replace the engine" — it's the split proposed in the issue: QuickJS keeps the dynamic glue, and a jz-compiled module handles the hot compute path as a peer in the same Wasmtime store.