Benchmarks

Standard Web APIs, the same script on each runtime (n/a where a runtime lacks one). Lower is better. esrun pairs near-instant startup with full WinterTC coverage and a JIT engine.

Runtimes measured

  • Node.js v24.21.0
  • Bun 1.4.3+c6da4a4d3
  • Deno deno 2.9.7 (stable, release, x86_64-unknown-linux-gnu)
  • LLRT LLRT v0.9.0-beta (linux, x64)
  • esrun esrun 0.38.0
How it's measured
  • Runtimes run interleaved, in randomized order each repetition — so they share one contention window and the ranking is fair, not decided by which ran first.

  • Each cell is the minimum over repetitions (the contention-free floor), after a discarded warmup.

  • Cells with high run-to-run variance are flagged in the raw output.

Standings at a glance

Who placed where across every metric — and a quick, neutral take on where each runtime fits in production. Pick by fit, not by medal count.

RuntimeTests🥇🥈🥉4th5th
Bun673615691
esrun6762716162
Deno65610231511
Node.js6689181813
LLRT511163724

Across 67 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 — parse cost and resolution cost are not the same thing. Memory is given twice: idle, and while a live working set is retained.

Cold start (near-empty script)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
4.2ms
12MB
Deno
13.3ms~
28MB
esrun
8.6ms
25MB
LLRT
3.6ms~
11MB
Node.js
22.3ms~
41MB
Parse + run ~100 KB script
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
14.7ms
16MB
Deno
22ms
29MB
esrun
20.6ms
26MB
LLRT
11.9ms
11MB
Node.js
34.1ms
44MB
Load a 300-module graph
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
12.4ms
24MB
Deno
32.1ms
33MB
esrun
34ms
27MB
LLRT
15.7ms
11MB
Node.js
99.3ms
61MB
Peak resident memory (idle)
Runtime
Performance (lower is better)
Bun
12MB
Deno
28MB
esrun
25MB
LLRT
11MB
Node.js
41MB
Peak resident memory (under load)
Runtime
Performance (lower is better)
Bun
138MB
Deno
122MB
esrun
106MB
LLRT
157MB
Node.js
134MB

~ varied more than 10% run to run; read as approximate.

Engine

Self-timed with performance.now() after an untimed JIT warmup; min of N interleaved runs, isolating engine cost from process launch.

Tight compute loop
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
110.4ms~
22MB
Deno
241.2ms
37MB
esrun
194.7ms
34MB
LLRT
1782.2ms
11MB
Node.js
197.8ms
53MB
JSON parse/stringify
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
152ms
37MB
Deno
201.2ms
54MB
esrun
196ms
52MB
LLRT
662.3ms
11MB
Node.js
265.6ms
78MB
JSON (large documents)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
369.6ms
116MB
Deno
520.2ms
136MB
esrun
491.4ms
91MB
LLRT
1648.5ms
122MB
Node.js
699.7ms
156MB
Regex (match, validate, replace)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
12.8ms
32MB
Deno
64.3ms
39MB
esrun
61.2ms
37MB
LLRT
977.5ms
11MB
Node.js
66.3ms
54MB
String building and slicing
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
60.9ms
42MB
Deno
61.4ms
46MB
esrun
58.9ms
44MB
LLRT
164.7ms
11MB
Node.js
63.4ms
63MB
structuredClone
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
240.9ms
29MB
Deno
266.8ms
42MB
esrun
288.9ms
50MB
LLRT
414.1ms
11MB
Node.js
218.1ms
54MB
throw/catch + stack capture
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
347.9ms
41MB
Deno
4290.1ms
62MB
esrun
386.4ms
45MB
LLRT
380.9ms
11MB
Node.js
1494.7ms
62MB
async/await throughput
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
39.8ms~
31MB
Deno
30.8ms
37MB
esrun
31.6ms
37MB
LLRT
581.8ms
11MB
Node.js
56.6ms
53MB
setTimeout churn
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
30.9ms
45MB
Deno
245.4ms
166MB
esrun
118.7ms
86MB
LLRT
47.1ms
44MB
Node.js
51.2ms
84MB

~ varied more than 10% run to run; read as approximate.

Web APIs

URL parsing
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
31.8ms
46MB
Deno
103ms
46MB
esrun
88.8ms
44MB
LLRT
124.5ms
11MB
Node.js
35.6ms
63MB
URL setter
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
90.6ms
46MB
Deno
190.5ms
45MB
esrun
185.6ms
45MB
LLRT
114.3ms
11MB
Node.js
142.6ms
62MB
URLPattern test
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
481.3ms
114MB
Deno
5234.8ms
64MB
esrun
885.9ms
42MB
LLRT
—
—
Node.js
411.4ms
71MB
TextEncoder/TextDecoder (small)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
17.4ms
36MB
Deno
71.8ms
49MB
esrun
66.1ms
46MB
LLRT
70.5ms
11MB
Node.js
69.5ms
64MB
TextEncoder/TextDecoder (64 KiB)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
69.2ms
36MB
Deno
148.6ms
38MB
esrun
110.6ms
38MB
LLRT
259.1ms
11MB
Node.js
320.1ms
53MB
base64 (atob/btoa)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
11.1ms
32MB
Deno
7.9ms
40MB
esrun
23.2ms
36MB
LLRT
34.1ms
11MB
Node.js
7.1ms
53MB
TypedArray / DataView
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
16.1ms
33MB
Deno
13.4ms
40MB
esrun
13.4ms
37MB
LLRT
73.5ms
11MB
Node.js
14.3ms
54MB
Headers / Request parsing
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
237.4ms
36MB
Deno
1597.8ms
124MB
esrun
454.3ms
47MB
LLRT
—
—
Node.js
452.6ms
74MB
Multipart FormData round trip
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
19ms
44MB
Deno
376.6ms
111MB
esrun
87.6ms
42MB
LLRT
4395.5ms
11MB
Node.js
272.2ms
121MB
Date + Intl formatting
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
79ms
38MB
Deno
139.9ms
46MB
esrun
138.1ms
42MB
LLRT
—
—
Node.js
124.2ms
61MB
ReadableStream piping
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
5.7ms
25MB
Deno
10.7ms
43MB
esrun
10.3ms
42MB
LLRT
94.2ms
11MB
Node.js
11.1ms
62MB
CompressionStream round trip (gzip)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
25.7ms~
37MB
Deno
78.9ms
54MB
esrun
67.9ms~
49MB
LLRT
—
—
Node.js
271.9ms
71MB

~ varied more than 10% run to run; read as approximate.

Crypto

SubtleCrypto SHA-256
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
409.6ms
35MB
Deno
399.3ms
40MB
esrun
332.2ms
33MB
LLRT
328.1ms
11MB
Node.js
501.1ms
57MB
HMAC + AES-GCM
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
79.2ms
31MB
Deno
91.8ms
40MB
esrun
34.1ms
35MB
LLRT
35.4ms
11MB
Node.js
177.9ms
61MB
ECDSA P-256 sign + verify
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
189.6ms
24MB
Deno
2192.2ms
41MB
esrun
1117.4ms
34MB
LLRT
1014.5ms
11MB
Node.js
408.6ms
57MB
PBKDF2 (10k iterations)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
62.4ms
19MB
Deno
70.3ms
32MB
esrun
98.4ms
27MB
LLRT
96.3ms
11MB
Node.js
70ms
46MB

Hashing

crypto above measures crypto.subtle — asynchronous, and returning bytes a caller still has to encode. These rows measure what that caller wanted: runtime:hashing for esrun, Bun.CryptoHasher for Bun, and node:crypto createHash for Node, Deno and LLRT. hash_fast has no standard answer in Node, Deno or LLRT, which is why they are n/a.

SHA-256 to hex (one-shot)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
175.9ms
34MB
Deno
180.3ms
54MB
esrun
312.6ms
37MB
LLRT
314.4ms
11MB
Node.js
158.4ms
58MB
SHA-256 over 64 KiB chunks (4 MiB)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
1639.7ms
21MB
Deno
1646.8ms
42MB
esrun
3032ms
30MB
LLRT
3031.5ms
11MB
Node.js
1456.9ms
54MB
Non-cryptographic hash (64 KiB)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
47ms
23MB
Deno
—
—
esrun
65.5ms
38MB
LLRT
—
—
Node.js
—
—
Where this leaves esrun, and why

hash_hex and sha256 are the same work — 20 000 × SHA-256 of the same 4 KiB buffer — one through async crypto.subtle, one through a synchronous call. esrun's two numbers are 7% apart (335 / 313), Node's are 3.2× apart (513 / 162): crypto.subtle here is the sync op with a resolved promise around it, so esrun wins the async row and loses the sync one, and neither is about the hash function. What is about the hash function is the remaining ~2× — RustCrypto's portable Rust against OpenSSL's hand-written AVX2 assembly, 274 MB/s to 571. The reference machine has no SHA-NI, so both sides run software SHA-256; sha2 selects an SHA-NI backend at runtime on hardware that has the instructions, which this box does not. hash_fast is what the module is actually for: 16× the data in a fifth of the time.

Network

HTTP server drives each runtime's own server from a client in the same process, so it measures the server and that runtime's fetch together, not the server alone. For server throughput on its own, bench/rps.sh uses an external load generator on separate cores.

fetch (local server)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
18.2ms
20MB
Deno
34.8ms
41MB
esrun
41.6ms
38MB
LLRT
17.9ms
11MB
Node.js
97.2ms
90MB
fetch (streamed upload)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
38.2ms~
24MB
Deno
38.3ms
43MB
esrun
42.8ms
38MB
LLRT
8715.9ms
11MB
Node.js
106ms
88MB
HTTP server (concurrent)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
49.1ms
31MB
Deno
105ms
71MB
esrun
112.5ms
63MB
LLRT
—
—
Node.js
363.1ms
153MB
WebSocket round trips
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
411.6ms
32MB
Deno
547.6ms
45MB
esrun
774.2ms
41MB
LLRT
—
—
Node.js
609.4ms
73MB
UDP round trips (10 000 datagrams)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
167.2ms
32MB
Deno
455.8ms
65MB
esrun
352.4ms
39MB
LLRT
355.8ms
11MB
Node.js
301.9ms
59MB
UDP send throughput (50 000 datagrams)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
175.4ms
26MB
Deno
254.3ms
41MB
esrun
208.9ms
37MB
LLRT
761.4ms
11MB
Node.js
238.3ms
59MB

~ varied more than 10% run to run; read as approximate.

Hello world, measured externally

A JSON hello-world response through Hono per runtime, driven by an external load generator on separate cores. LLRT has no general HTTP server, so it is not in this set. The memory column is peak RSS for the whole server process during the run, not JavaScript heap alone; it includes the runtime, framework, and server path. This measurement shows the footprint but does not attribute it to a specific layer.

Hono hello-world · Speed & Memory
Runtime
Throughput (higher ↑)
Memory (lower ↓)
Bun
70.2k
45 MB
Deno
73.3k
51 MB
esrun
48.9k
53 MB
Node.js
30.1k
111 MB

Burst vs sustained

The chart above is a burst against a freshly started process. Held under load instead, the heap is full and the collector has been running throughout.

RuntimeBurst req/s60s req/sChange
Bun70.2k71.6k+2.1%
Deno73.3k72.2k-1.4%
esrun48.9k49.8k+2.0%
Node.js30.1k30.8k+2.5%

Static files, measured externally

A 64 KiB file per response, driven by an external load generator on separate cores — the server alone, unlike the in-process row above. Bun and Deno hand a file handle to the kernel; Node streams it; esrun reads it host-side, outside the JavaScript heap.

HTTP requests/sec · 64 KiB static file
Runtime
Throughput (higher ↑)
Memory (lower ↓)
Bun
29.8k
35 MB
Deno
14.1k
152 MB
esrun
19.3k
65 MB
Node.js
11.9k
282 MB

Filesystem

Page-cache resident, no fsync — this is the runtime's cost above the syscall, not disk speed. Numbers are specific to the filesystem in environment.

File read (small)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
36.1ms
33MB
Deno
38.9ms~
40MB
esrun
41.2ms
36MB
LLRT
26.2ms~
11MB
Node.js
109.2ms
55MB
File read (large)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
17.6ms
36MB
Deno
64.4ms
165MB
esrun
27.4ms
91MB
LLRT
13.3ms
15MB
Node.js
59.9ms
112MB
File write (small)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
12.5ms
22MB
Deno
82.4ms
40MB
esrun
75.8ms
32MB
LLRT
88.8ms
11MB
Node.js
142.3ms
55MB
File write (large)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
20.2ms
25MB
Deno
40.1ms~
79MB
esrun
38.6ms
32MB
LLRT
51.8ms
14MB
Node.js
56.9ms
91MB
File append (small)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
30.6ms
24MB
Deno
39.7ms
40MB
esrun
35.5ms
32MB
LLRT
—
—
Node.js
93.5ms
55MB
File append (large)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
7.5ms~
20MB
Deno
15.3ms~
49MB
esrun
8.4ms~
28MB
LLRT
—
—
Node.js
23ms
60MB
File stat (one path)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
48.8ms
25MB
Deno
89.2ms
39MB
esrun
54ms
32MB
LLRT
40ms
11MB
Node.js
64.2ms~
49MB
File stat (1000 paths)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
199.2ms
32MB
Deno
357.3ms
44MB
esrun
233ms
39MB
LLRT
157.5ms~
11MB
Node.js
261.6ms
59MB
File exists (one path)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
7.6ms
25MB
Deno
88.8ms~
39MB
esrun
39.3ms
32MB
LLRT
46.6ms
11MB
Node.js
58.8ms
49MB
File exists (1000 paths)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
31.9ms
36MB
Deno
362.2ms
44MB
esrun
176.9ms
33MB
LLRT
184.5ms
11MB
Node.js
249.5ms
59MB
Glob scan
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
24.7ms
30MB
Deno
—
—
esrun
47.3ms
34MB
LLRT
—
—
Node.js
181.7ms
65MB

~ varied more than 10% run to run; read as approximate.

System

Starting a child process and collecting its output — Deno.Command, Bun.spawn, node:child_process, and esrun's runtime:system Command.

Spawn a child process
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
96ms
24MB
Deno
105.2ms
50MB
esrun
79.3ms
34MB
LLRT
93.3ms
11MB
Node.js
200.9ms
53MB

Tracing and async context

Measured separately, by bench/probe-tracing.sh, and shown where the design is explained:

  • Async context — propagation cost against Node, Bun and Deno, and the promise-hook overhead that is a known gap.

  • Diagnostics — one op with nothing subscribed, with a subscription, and under --otel.

Behaviour under memory exhaustion

What happens when a script asks for more than the machine can give. The question is not speed but whether the guest survives: a catchable error is a failed request, a signal is a dropped process.

ScenarioBunDenoesrunLLRTNode.js
200k-deep nested array → JSON.stringifygracefulgracefulgracefulSIGSEGVgraceful
String doubled past the engine maximumgracefulgracefulgracefulgracefulgraceful
10M chained .then()gracefulgracefulgracefulgracefulgraceful

Native vs third-party parsers

Each runtime parses with the best facility it ships: esrun's runtime:serialization, Bun's native Bun.YAML and Bun.TOML, LLRT's llrt:xml. Node and Deno ship no parser for these formats, so they use the libraries you would reach for anyway (js-yaml, @iarna/toml, fast-xml-parser, msgpackr) — a native-vs-library gap on those rows, not a runtime-vs-runtime one.

JSONL streaming (large dataset)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
838.3ms
83MB
Deno
691.2ms
143MB
esrun
589.1ms
69MB
LLRT
—
—
Node.js
608.2ms
216MB
XML parsing (small dataset)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
510.2ms
89MB
Deno
420.8ms
67MB
esrun
152.9ms
31MB
LLRT
56.8ms
11MB
Node.js
483.8ms
78MB
XML parsing (large dataset)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
884.5ms
120MB
Deno
868.9ms
205MB
esrun
309.3ms
82MB
LLRT
115.1ms
17MB
Node.js
982.4ms
277MB
YAML parsing (small dataset)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
103.9ms
47MB
Deno
203.3ms
47MB
esrun
224.2ms
32MB
LLRT
4545.9ms
11MB
Node.js
215.9ms
62MB
YAML parsing (large dataset)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
192.9ms
60MB
Deno
390.7ms
122MB
esrun
453.9ms
62MB
LLRT
9170.3ms
18MB
Node.js
413ms
138MB
TOML parsing (small dataset)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
53.2ms
50MB
Deno
241.5ms
59MB
esrun
168.1ms
33MB
LLRT
4100.4ms
11MB
Node.js
208.2ms
66MB
TOML parsing (large dataset)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
100.3ms
62MB
Deno
473.1ms
196MB
esrun
323.2ms
64MB
LLRT
7959.9ms
17MB
Node.js
391.6ms
211MB
MessagePack parsing (small dataset)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
45.2ms
54MB
Deno
37.4ms
58MB
esrun
49.8ms
33MB
LLRT
804.8ms
11MB
Node.js
41.3ms
61MB
MessagePack parsing (large dataset)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
50.1ms
63MB
Deno
40ms
73MB
esrun
54.8ms
62MB
LLRT
799.9ms
16MB
Node.js
41.5ms
80MB

Protobuf (each runtime's own path)

esrun decodes with its native runtime:serialization Protobuf (pure-JS, reflective); Node, Bun, and Deno decode with protobuf-es (@bufbuild/protobuf). Identical bytes, each runtime's real protobuf path.

Protobuf decode (small dataset)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
69.8ms~
56MB
Deno
70.6ms
50MB
esrun
73.8ms
42MB
LLRT
1016.8ms
12MB
Node.js
57.9ms
66MB
Protobuf decode (large dataset)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
350.1ms
136MB
Deno
399.9ms
237MB
esrun
388.9ms
165MB
LLRT
5212.7ms
86MB
Node.js
325.6ms
302MB

~ varied more than 10% run to run; read as approximate.

WebAssembly

esrun reaches V8's own WebAssembly implementation through the standard JS API, as Node and Deno do; Bun uses JavaScriptCore's. LLRT has no WebAssembly at all, so every cell here is n/a for it.

WebAssembly.compile (~250 KB module)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
62ms
136MB
Deno
37.7ms
225MB
esrun
37.8ms
150MB
LLRT
—
—
Node.js
44.7ms
319MB
JS↔wasm call boundary
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
148.8ms
29MB
Deno
78.9ms
40MB
esrun
78.9ms
37MB
LLRT
—
—
Node.js
91.1ms
54MB
wasm linear memory (shared buffer)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
366.2ms
29MB
Deno
239.1ms
42MB
esrun
247.8ms
38MB
LLRT
—
—
Node.js
201.4ms
54MB

The three measure different things. Compile is validation plus baseline codegen on a ~250 KB module, with a different salt each iteration so nothing is served from a code cache. The call boundary (20M calls into a trivial exported add) and linear memory (JS fills a 64 KiB window, wasm sums it back) are where real interop spends its time. These workloads exercise different parts of V8; compare each row separately, since their rankings differ.

WASI

wasm32-wasip1, which is what a compiled CLI targets. Bootstrap is 2,000 x (construct a WASI, instantiate a command module, run _start) with compilation hoisted out, so it is the per-invocation cost of running a wasm program. Syscalls runs a guest whose _start loops 20,000 times over random_get and clock_time_get — the calls come from inside wasm, where a real program makes them, so it measures the host-side preview-1 implementation.

WASI bootstrap (instantiate + _start)
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
390.9ms
33MB
Deno
48.5ms
61MB
esrun
43.5ms
39MB
LLRT
—
—
Node.js
276.1ms
76MB
WASI syscalls from inside wasm
Runtime
Performance (lower is better)
Memory (lower ↓)
Bun
13.3ms
42MB
Deno
17.7ms
52MB
esrun
55.5ms
41MB
LLRT
—
—
Node.js
44ms
53MB

esrun's bootstrap is the fastest measured, a little ahead of Deno and several times quicker than Node or Bun. Its syscall path is not: Deno's is around 3x faster, because esrun implements preview-1 in JS rather than natively. Bun is also faster on that workload in the current sample.

WebSocket chat broadcast

C clients in one room; the server fans every message out to all of them. Cells are RECV messages/sec (fan-out), 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, so it's not in this sweep.

ServerC=32C=64C=128
Bun249,707250,869250,587
Deno214,127212,561208,206
esrun271,103109,51148,070

esrun serves through the capability-secured driven seam (each connection a spawned actor), not a native pub/sub server. A batched broadcast() leads at low fan-out; as C grows the central connection-map lock costs throughput while Bun/Deno hold flat.

Client throughput

Fixed Bun server; each runtime's WebSocket client under the same broadcast load — esrun lands on par with node, Bun and Deno.

ClientC=32C=64C=128
Bun249,119253,889250,898
Deno253,637253,986248,922
esrun254,513258,114257,717
Node.js260,365260,239259,744

See the WebSocket guide.

HTTP/1.1 vs HTTP/2

Each runtime's hello-world server is measured over HTTP/1.1 and cleartext HTTP/2 (h2c), where supported. Node and Bun use separate server APIs for h2 because their default servers do not support it; their ratios therefore include an implementation change as well as the protocol change. Cells are req/sec, best of 3 interleaved repetitions. Higher is better.

Unlike the charts above, this table is driven by an external load generator against each runtime's own server, and it is regenerated on its own (bench/http2.sh) rather than as part of the full sweep — so treat it as a separate run, not as another row of the same experiment.

RuntimeWide — 50 conns × 1 streamNarrow — 1 conn × 50 streams
HTTP/1.1HTTP/2GainHTTP/1.1HTTP/2Gain
Bun86,61437,1820.43׆31,71950,1051.58׆
Deno88,59323,1610.26×33,08642,1501.27×
esrun48,91339,3650.80×18,73471,1223.80×
Node.js32,22816,1010.50׆23,75743,2291.82׆

Two shapes, because an HTTP/2 number on its own says nothing — it is dominated by how the client connected:

  • Wide (50 connections, 1 stream each) is HTTP/1.1's best case: it already has 50 sockets, so there is nothing to multiplex and h2 is pure framing overhead. Every runtime loses here, esrun least of them.

  • Narrow (1 connection, 50 streams) is what HTTP/2 is for. HTTP/1.1 on one connection is strictly serial — the next request goes out only after the previous response comes back — while HTTP/2 carries all 50 at once. This is the shape a reverse proxy, an API gateway, or a gRPC client is in.

esrun leads the narrow HTTP/2 column outright, from the slowest HTTP/1.1 baseline on that shape: runtime:http hands responses back per request rather than per connection, so multiplexed streams could always complete out of order and the feature needed no new machinery to exploit.

Which comparisons this table licenses

Compare down a column freely — one client shape against each runtime's best available server. The gain column only isolates the protocol for rows without a †: Node and Bun serve cleartext h2 from node:http2 while their HTTP/1.1 number comes from node:http/Bun.serve, so their ratio also carries a change of implementation. And don't compare gains across runtimes — a weaker HTTP/1.1 baseline inflates a multiple, which is part of why esrun's is the largest.

See the HTTP/2 section of the HTTP guide.

Redis clients

esrun with @opentf/esrun-redis against Bun's built-in RedisClient and, on Node and Deno, both npm clients — redis (node-redis, the official one) and ioredis (roughly twice the downloads). Both are kept because they do not perform alike, and standing one in for the other would misreport Node.

Wall ms, min of 5, lower is better. Every workload prints a checksum the runner compares; all six columns agreed on all five.

WorkloadesrunNode
node-redis
Node
ioredis
Bun
built-in
Deno
node-redis
Deno
ioredis
serial_set — 5 000 SET, one at a time85810299106661030886
serial_get — 5 000 GET, one at a time85410408846451032873
pipeline — 20 000 SET in one batch22537420372366186
list — LRANGE over 50 000 elements15820311928209111
hash — 200 × HGETALL of 1 000 fields957479496205459494

Peak RSS, MB:

WorkloadesrunNode
node-redis
Node
ioredis
Bun
built-in
Deno
node-redis
Deno
ioredis
serial_set38.4112.379.133.6124.499.6
serial_get38.8112.979.634.0123.599.2
pipeline123.8181.1129.144.3191.6139.5
list92.996.186.540.7103.886.7
hash78.7101.497.245.0114.1113.7

Bun's client leads everything, by 1.3× on the round trips and 3–5× where decoding dominates. It is native C++ against four JavaScript clients, and the gap is what that buys.

Among the JavaScript clients, esrun is fastest where the round trip dominates — 858 ms against node-redis's 1029 and ioredis's 910 — and does it in a third of the memory (38 MB against 79–124). It is also ahead of the official node-redis on the batch (225 vs 374) and the list scan (158 vs 203).

One workload is a genuine loss: repeated HGETALL at 957 ms against ~480 ms for both npm clients, twice as slow. That is not decoding in general — the list scan is mid-pack — it is specifically the map path.

What the map gap is, and what it is not

Not UTF-8 decoding: reading the same 50 000-element reply with { binary: true }, which skips every TextDecoder call, was 2% faster. Not startup either — esrun launches an empty script in about 9 ms against Node's 22 and Deno's 13.

What is left is how a RESP3 map is represented on the way to the object a caller asked for: a pair array of wrapper objects, each holding a copied Uint8Array, before anything is turned into a key or a value. At 200 000 field/value pairs that is roughly four allocations each. ioredis negotiates RESP3 too and returns the same object twice as fast, so this is a fixable implementation cost rather than a cost of the protocol.

Unlike the rest of this page, these runs are not interleaved — each column is measured in turn — so treat small differences as noise and the order of magnitude as the finding. Reproduce with bench/db/redis/run.sh.

Images

Each runtime's own way of making a thumbnail: runtime:images for esrun, Bun.Image for Bun, createImageBitmap with an OffscreenCanvas for Deno, and, since Node.js has no image API, sharp (libvips), which Node applications use. Every workload starts 40 pipelines at once, which is how a server meets uploads. Each runtime uses its defaults, including its thread pool. Wall ms, min of 5, lower is better; n/a where a runtime cannot write the format. Every workload prints a checksum of the output dimensions, and the runner refuses a run in which they disagree.

WorkloadBun
Bun.Image
Deno
createImageBitmap
esrun
runtime:images
Node.js
sharp
12 MP JPEG → 400 px WebP168 msn/a516 ms353 ms
12 MP JPEG → 400 px JPEG115 ms49,993 ms247 ms243 ms
1280×800 PNG → 320 px JPEG60 ms4,398 ms234 ms207 ms
12 MP JPEG → 400 px AVIFn/an/a683 ms6,536 ms

Size of one output. Encoders trade speed against size, so a time is only comparable between outputs of similar size. Every runtime is asked for quality 80.

WorkloadBun
Bun.Image
Deno
createImageBitmap
esrun
runtime:images
Node.js
sharp
12 MP JPEG → 400 px WebP16.2 KBn/a16.5 KB16.1 KB
12 MP JPEG → 400 px JPEG24.4 KB35.9 KB23.3 KB23.1 KB
1280×800 PNG → 320 px JPEG16.4 KB19.9 KB16.4 KB15.4 KB
12 MP JPEG → 400 px AVIFn/an/a24.3 KB23.4 KB

Peak resident memory of the whole process, lower is better:

WorkloadBun
Bun.Image
Deno
createImageBitmap
esrun
runtime:images
Node.js
sharp
12 MP JPEG → 400 px WebP123.3 MBn/a144 MB98.1 MB
12 MP JPEG → 400 px JPEG109.7 MB196.9 MB109.7 MB96.9 MB
1280×800 PNG → 320 px JPEG128.8 MB61.2 MB319.1 MB139.5 MB
12 MP JPEG → 400 px AVIFn/an/a220.2 MB189.4 MB

esrun's codecs are otf-pixels' own, written in Rust; Bun and sharp use C libraries (libjpeg-turbo, spng, libwebp; libvips with mozjpeg, libpng, libwebp, and libheif with libaom). Deno's encoder writes PNG and JPEG only. Where esrun is slower, the time is in Pixels' codecs; see Internals: images. Reproduce with bench/images/run.sh.

Reproduce

The harness lives in bench/. It auto-detects installed runtimes and can emit the JSON that powers these charts:

Shell
# 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

For CLI/script benchmarking, tools like hyperfine are also a good fit. Numbers vary by hardware; treat them as relative, not absolute.

Last updated on
Edit this page