Benchmarks
Standard Web APIs, the same script on each runtime (n/a where a runtime lacks one). Lower is better.
Runtimes measured
- Node.js v26.13.0
- Bun 1.4.4+38afee6c8
- Deno deno 2.9.7 (stable, release, x86_64-unknown-linux-gnu)
- LLRT LLRT v0.9.0-beta (linux, x64)
- esrun esrun 0.45.0
Runtimes run interleaved, in randomized order each repetition — one shared contention window.
Each cell is the minimum over repetitions (the contention-free floor), after a discarded warmup.
High-variance cells are flagged in the raw output.
Standings at a glance
Who placed where across every metric. Pick by fit, not by medal count.
| Runtime | Tests | 🥇 | 🥈 | 🥉 | 4th | 5th |
|---|---|---|---|---|---|---|
| 74 | 36 | 17 | 11 | 10 | · | |
| 74 | 9 | 24 | 22 | 16 | 3 | |
| 73 | 14 | 9 | 21 | 14 | 15 | |
| 72 | 4 | 17 | 15 | 28 | 8 | |
| 53 | 11 | 7 | 4 | 4 | 27 |
Across 74 comparable metrics, each ranked by its own better direction. Each runtime is ranked only on metrics it can run, so totals differ — e.g. LLRT has no HTTP server or filesystem here. Place counts are not a ranking: more 1st-place finishes does not mean “best overall.”
Startup & footprint
Process wall-time (min of N runs). bigscript is one large file, modules a 300-module graph; memory twice — idle, and with a live set retained.
Engine
Self-timed with performance.now() after an untimed JIT warmup — engine cost, not process launch; min of N interleaved runs.
~ varied more than 10% run to run; read as approximate.
Web APIs
~ varied more than 10% run to run; read as approximate.
Crypto
~ varied more than 10% run to run; read as approximate.
Hashing
crypto above is async crypto.subtle; these rows are what the caller wanted — encoded hashes, synchronously. runtime:hashing on esrun, Bun.CryptoHasher, node:crypto elsewhere; hash_fast only exists on esrun and Bun.
hash_hex and sha256 are the same work — 20 000 × SHA-256 of one 4 KiB buffer — async vs sync (esrun 7% apart, Node 3.2×: crypto.subtle here is the sync op with a resolved promise around it). The remaining ~2× is RustCrypto's portable Rust vs OpenSSL's AVX2 assembly — on SHA-NI hardware, which this box is not, it's a different measurement.
Network
HTTP server runs client and server in one process, so it mixes both halves. bench/rps.sh measures the server alone, externally.
~ varied more than 10% run to run; read as approximate.
Hello world, measured externally
JSON hello-world through Hono per runtime (LLRT has no HTTP server), driven externally on split cores — oha/bombardier, since autocannon caps everything at ~35–40k. Memory is whole-process peak RSS: footprint, not attribution.
Burst vs sustained
The chart is a burst against a fresh process; held under load, the heap is full and the collector never stops.
| Runtime | Burst req/s | 60s req/s | Change |
|---|---|---|---|
| Bun | 82.1k | 76.2k | -7.2% |
| Deno | 78.3k | 69.6k | -11.1% |
| esrun | 49.3k | 50.1k | +1.5% |
| Node.js | 40.4k | 40.5k | +0.4% |
Static files, measured externally
A 64 KiB file per response, external load generator on separate cores. Bun and Deno hand a file handle to the kernel; Node streams; esrun reads host-side.
Bare plain-text is the earlier sample, Hono JSON the 2026-10-01 refresh — compare down a column, never across it. Higher is better.
| Runtime | Bare server req/sec | Through Hono req/sec |
|---|---|---|
| Deno | 85,070 | 69,741 |
| Bun | 82,615 | 73,951 |
| esrun | 49,537 | 50,601 |
| Node | 29,558 | 30,872 |
The Hono column serves {"message":"Hello, World!"} through unmodified npm ESM off node_modules — esrun at 50,601 req/s against Bun's 73,951.
esrun, Bun and Deno each saturate ~one core here — a per-request gap, not a core-count one. The stable measure is server CPU time per request:
| Server | CPU µs/req | ~req/s on 1 core |
|---|---|---|
| Bare hyper, no JS (transport floor) | ~10.4 | — |
| Deno | ~11.9 | ~84k |
| Bun | ~12.2 | ~82k |
| esrun | ~18.2 | ~55k |
| Node | ~33.8 | ~30k |
Bun/Deno add ~2µs of JS-handler overhead over bare hyper; esrun adds ~8µs — the injectable-provider + driven-loop seam, tuned from ~29µs via batched accept, structured metadata, lazy bodies/headers, and the host-validated URL.
Filesystem
Page-cache resident, no fsync — the runtime's cost above the syscall, not disk speed. Numbers are specific to the filesystem in environment.
~ varied more than 10% run to run; read as approximate.
System
One child process per call — start /bin/echo, drain stdout, wait. The bytes back are the runtime's half; fork/exec is the kernel's.
Tracing and async context
Measured separately by bench/probe-tracing.sh; shown where each design is explained:
Async context — propagation cost vs Node, Bun, Deno, incl. the known promise-hook overhead.
Diagnostics — one op idle, subscribed, and under
--otel.
Behaviour under memory exhaustion
Not speed but survival: a catchable error is a failed request, a signal is a dropped process.
| Scenario | Bun | Deno | esrun | LLRT | Node.js |
|---|---|---|---|---|---|
| 200k-deep nested array → JSON.stringify | graceful | graceful | graceful | SIGSEGV | graceful |
| String doubled past the engine maximum | graceful | graceful | graceful | graceful | graceful |
| 10M chained .then() | graceful | graceful | graceful | graceful | graceful |
Native vs third-party parsers
Each runtime parses with the best facility it ships; where one ships nothing, the library you'd reach for anyway. Native-vs-library gaps, not runtime-vs-runtime.
~ varied more than 10% run to run; read as approximate.
esrun's runtime:serialization, Bun's native Bun.YAML/Bun.TOML, LLRT's llrt:xml; js-yaml, @iarna/toml, fast-xml-parser, msgpackr, cbor-x, Deno's @std/cbor/@std/csv, and csv-parse on Node and Bun.
Protobuf (each runtime's own path)
esrun's native runtime:serialization Protobuf (pure-JS, reflective) against protobuf-es elsewhere. Identical bytes, each runtime's real path.
~ varied more than 10% run to run; read as approximate.
WebAssembly
V8's own WebAssembly on esrun, Node and Deno; JavaScriptCore on Bun; LLRT has none.
| Row | What it measures |
|---|---|
| Compile | Validation + baseline codegen on a ~250 KB module, salted per iteration — no code cache. |
| Call boundary | 20M JS↔wasm calls into a trivial exported add. |
| Linear memory | JS fills a 64 KiB window, wasm sums it back. |
WASI
wasm32-wasip1, as a compiled CLI targets it. Bootstrap is 2,000 runs with compilation hoisted out; syscalls loop 20,000 random_get + clock_time_get from inside wasm.
Fastest bootstrap — ahead of Deno, several times Node/Bun — but on syscalls Deno is ~3× faster and Bun too: esrun's preview-1 is JS, theirs native.
WebSocket chat broadcast
C clients in one room; the server fans every message out. Cells are RECV messages/sec, best of 3 — higher is better.
Server fan-out
Each runtime's built-in server, fixed Bun client driver. Node has no built-in WS server.
| Server | C=32 | C=64 | C=128 |
|---|---|---|---|
| Bun | 253,663 | 253,033 | 254,755 |
| Deno | 214,788 | 214,998 | 211,573 |
| esrun | 275,124 | 144,801 | 107,324 |
esrun serves through the driven seam (one actor per connection), not native pub/sub — batched broadcast() leads low, the connection-map lock costs at scale.
Client throughput
Fixed Bun server; each runtime's client under the same load — esrun lands on par with Node, Bun and Deno.
| Client | C=32 | C=64 | C=128 |
|---|---|---|---|
| Bun | 253,239 | 253,109 | 252,681 |
| Deno | 254,948 | 255,126 | 253,845 |
| esrun | 260,258 | 259,599 | 258,454 |
| Node.js | 259,818 | 259,500 | 262,983 |
See the WebSocket guide.
HTTP/1.1 vs HTTP/2
Each runtime's hello-world server over HTTP/1.1 and cleartext h2, regenerated by bench/http2.sh — a separate run, not another row. Cells are req/sec, best of 3.
| Shape | What it is |
|---|---|
| Wide (50 conns × 1 stream) | HTTP/1.1's best case — h2 is pure framing overhead, and every runtime loses. |
| Narrow (1 conn × 50 streams) | What h2 is for (H1 is strictly serial here) — esrun leads outright, from the slowest H1 baseline. |
| Runtime | Wide — 50 conns × 1 stream | Narrow — 1 conn × 50 streams | ||||
|---|---|---|---|---|---|---|
| HTTP/1.1 | HTTP/2 | Gain | HTTP/1.1 | HTTP/2 | Gain | |
| Bun | 93,086 | 38,579 | 0.41׆ | 31,297 | 47,262 | 1.51׆ |
| Deno | 95,630 | 22,650 | 0.24× | 32,001 | 42,404 | 1.33× |
| esrun | 48,888 | 39,780 | 0.81× | 18,732 | 69,237 | 3.70× |
| Node.js | 50,576 | 17,090 | 0.34׆ | 26,572 | 45,531 | 1.71׆ |
Compare down a column — one client shape, each runtime's best server. The gain column isolates the protocol only where the server didn't change (Node and Bun serve h2 from node:http2 †). Never compare gains across runtimes: a weaker H1 baseline inflates a multiple.
See the HTTP/2 section of the HTTP guide.
Redis clients
esrun with @opentf/esrun-redis against Bun's built-in client and both npm clients (redis and ioredis) on Node and Deno — they don't perform alike, so one can't stand in for the other.
Wall ms, min of 5, lower is better. Checksums agreed on all five workloads.
| Workload | esrun | Node node-redis | Node ioredis | Bun built-in | Deno node-redis | Deno ioredis |
|---|---|---|---|---|---|---|
serial_set — 5 000 SET, one at a time | 858 | 1029 | 910 | 1030 | 886 | |
serial_get — 5 000 GET, one at a time | 854 | 1040 | 884 | 1032 | 873 | |
pipeline — 20 000 SET in one batch | 225 | 374 | 203 | 366 | 186 | |
list — LRANGE over 50 000 elements | 158 | 203 | 119 | 209 | 111 | |
hash — 200 × HGETALL of 1 000 fields | 957 | 479 | 496 | 459 | 494 |
Peak RSS, MB:
| Workload | esrun | Node node-redis | Node ioredis | Bun built-in | Deno node-redis | Deno ioredis |
|---|---|---|---|---|---|---|
serial_set | 38.4 | 112.3 | 79.1 | 124.4 | 99.6 | |
serial_get | 38.8 | 112.9 | 79.6 | 123.5 | 99.2 | |
pipeline | 123.8 | 181.1 | 129.1 | 191.6 | 139.5 | |
list | 92.9 | 96.1 | 86.5 | 103.8 | 86.7 | |
hash | 78.7 | 101.4 | 97.2 | 114.1 | 113.7 |
Fastest where the round trip dominates (858 ms vs 1029/910) at a third of the memory (38 vs 79–124 MB) — and ahead of node-redis on batch and list. Bun's native C++ leads everything: 1.3× on round trips, 3–5× on decode.
957 ms vs ~480 on repeated HGETALL — the map path only, not decoding (list scan is mid-pack) and not UTF-8 (binary: true was 2% faster). A RESP3 map crosses as a pair array of wrapper objects holding copied Uint8Arrays — ~4 allocations per field — where ioredis builds the same object twice as fast. Fixable implementation cost.
Unlike the rest of this page, these runs are not interleaved — treat small gaps as noise. Reproduce with bench/db/redis/run.sh.
SQLite
esrun's runtime:db (async, Turso 0.8.3) against synchronous in-process SQLite: node:sqlite on Node and Deno, bun:sqlite on Bun. Same schema, same rows, agreeing checksums.
Wall ms, min of 5, lower is better.
| Workload | esrun | Node | Bun | Deno |
|---|---|---|---|---|
open — fresh file + schema | 26.3 | 33.0 | 30.9 | |
insert — 50 000 rows, one statement each | 1198.4 | 187.3 | 204.1 | |
insert_many — the same 50 000 in one batch | 391.1 | n/a | n/a | n/a |
scan_num — full scan, 3 numeric columns | 41.4 | 98.5 | 123.8 | |
scan_text — full scan, 2 text columns | 52.1 | 91.5 | 118.0 | |
point — 5 000 lookups by id | 162.5 | 81.4 | 76.0 | |
stream — 200 000-row scan through the cursor | 77.9 | 201.9 | 312.9 |
Peak RSS, MB:
| Workload | esrun | Node | Bun | Deno |
|---|---|---|---|---|
open | 36.2 | 46.8 | 36.0 | |
insert | 64.3 | 59.5 | 52.3 | |
insert_many | 149.6 | n/a | n/a | n/a |
scan_num | 53.9 | 59.7 | 57.8 | |
scan_text | 55.4 | 59.6 | 57.2 | |
point | 47.3 | 50.2 | 41.3 | |
stream | 61.9 | 65.4 | 64.9 |
Per-statement crossings are esrun's cost — 1198 ms for 50 000 single-row executes vs ~150–200 sync — and insert_many is the answer: one crossing, 391 ms. The streaming scan lands second (78 vs 202/313); only point lookups lose outside the boundary (163 vs 52–81).
One shared definition (bench/db/workload.mjs); insert_many is esrun-only, the sync APIs have no equivalent. Memory is mid-pack except the batch, which holds all rows in JS up front.
Unlike the rest of this page, these runs are not interleaved — treat small gaps as noise. Reproduce with bench/db/run.sh.
Images
40 thumbnails at once, as a server meets uploads — each runtime's own pipeline. Wall ms, min of 5.
runtime:images, Bun.Image, Deno's createImageBitmap + OffscreenCanvas, sharp (libvips) on Node. Defaults everywhere, quality 80; checksums on output dimensions, refused on mismatch.
| Workload | Bun Bun.Image | Deno createImageBitmap | esrun runtime:images | Node.js sharp |
|---|---|---|---|---|
| 12 MP JPEG → 400 px WebP | 167 ms | n/a | 504 ms | 343 ms |
| 12 MP JPEG → 400 px JPEG | 116 ms | 49,171 ms | 242 ms | 237 ms |
| 1280×800 PNG → 320 px JPEG | 60 ms | 4,338 ms | 227 ms | 207 ms |
| 12 MP JPEG → 400 px AVIF | n/a | n/a | 674 ms | 6,515 ms |
| 12 MP progressive JPEG → 400 px WebP | 438 ms | n/a | 931 ms | 850 ms |
pjpeg_webp decodes slower than jpeg_webp on every runtime — compare it only against itself (Bun leads).
Size of one output — a time is only comparable between similar sizes:
| Workload | Bun Bun.Image | Deno createImageBitmap | esrun runtime:images | Node.js sharp |
|---|---|---|---|---|
| 12 MP JPEG → 400 px WebP | 16.2 KB | n/a | 16.5 KB | 16.1 KB |
| 12 MP JPEG → 400 px JPEG | 24.4 KB | 35.9 KB | 23.3 KB | 23.1 KB |
| 1280×800 PNG → 320 px JPEG | 16.4 KB | 19.9 KB | 16.4 KB | 15.4 KB |
| 12 MP JPEG → 400 px AVIF | n/a | n/a | 24.3 KB | 23.4 KB |
| 12 MP progressive JPEG → 400 px WebP | 16.2 KB | n/a | 16.5 KB | 16.1 KB |
Whole-process peak RSS:
| Workload | Bun Bun.Image | Deno createImageBitmap | esrun runtime:images | Node.js sharp |
|---|---|---|---|---|
| 12 MP JPEG → 400 px WebP | 127.7 MB | n/a | 150.4 MB | 99.7 MB |
| 12 MP JPEG → 400 px JPEG | 109.6 MB | 199.1 MB | 103.6 MB | 100.6 MB |
| 1280×800 PNG → 320 px JPEG | 123 MB | 64.6 MB | 342 MB | 143 MB |
| 12 MP JPEG → 400 px AVIF | n/a | n/a | 217.4 MB | 194.6 MB |
| 12 MP progressive JPEG → 400 px WebP | 605.9 MB | n/a | 1507.3 MB | 751.4 MB |
Where esrun is slower, the time is in Pixels' codecs.
esrun's are otf-pixels' own Rust; Bun and sharp use C libraries, Deno's encoder writes PNG and JPEG only. See Internals: images; reproduce with bench/images/run.sh.
Eight documents at once: the text of a 200-page report, the report merged with itself, and its first ten pages split off. Wall ms, min of 5.
runtime:pdf on esrun. Node.js, Bun and Deno have no PDF support built in, so each runs pdf.js (pdfjs-dist) for text and pdf-lib to edit. Checksums on the extracted words and the pages written, refused on mismatch.
| Workload | Bun pdf.js + pdf-lib | Deno pdf.js + pdf-lib | esrun runtime:pdf | Node.js pdf.js + pdf-lib |
|---|---|---|---|---|
| Text of a 200-page report | 1,910 ms | 2,360 ms | 283 ms | 2,663 ms |
| Merge it with itself (400 pages) | 464 ms | 465 ms | 42 ms | 528 ms |
| Split off its first 10 pages | 266 ms | 213 ms | 20 ms | 314 ms |
Whole-process peak RSS:
| Workload | Bun pdf.js + pdf-lib | Deno pdf.js + pdf-lib | esrun runtime:pdf | Node.js pdf.js + pdf-lib |
|---|---|---|---|---|
| Text of a 200-page report | 197.4 MB | 258.4 MB | 58.2 MB | 311.2 MB |
| Merge it with itself (400 pages) | 180.5 MB | 148.6 MB | 91.4 MB | 189.9 MB |
| Split off its first 10 pages | 124.2 MB | 99.8 MB | 47.3 MB | 143.8 MB |
Reproduce with bench/pdf/run.sh; see Internals: PDF.
Reproduce
The harness lives in bench/, auto-detects runtimes, and emits the JSON behind these charts:
# human-readable table bench/run.sh # one section only — see `bench/run.sh --list` GROUP=fs bench/run.sh # regenerate the data module this page renders bench/gen-bench-data.sh # one section, or a few rows, merged into the rest SECTIONS=rps_sustained bench/gen-bench-data.sh bench/gen-bench-data.sh regex strings # just the HTTP/1.1 vs HTTP/2 table bench/http2.sh
Numbers vary by machine — treat them as relative, not absolute. For CLI/script benchmarking, hyperfine is also a good fit.