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.14.0
- Bun 1.4.2+744846f84
- Deno deno 2.9.5 (stable, release, x86_64-unknown-linux-gnu)
- LLRT LLRT v0.8.0-beta (linux, x64)
- esrun esrun 0.29.0
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.
| Runtime | Tests | 🥇 | 🥈 | 🥉 | 4th | 5th |
|---|---|---|---|---|---|---|
| Bun | 67 | 33 | 13 | 10 | 10 | 1 |
| esrun | 67 | 9 | 25 | 14 | 16 | 3 |
| Deno | 65 | 5 | 14 | 21 | 14 | 11 |
| Node.js | 66 | 8 | 9 | 19 | 17 | 13 |
| LLRT | 50 | 12 | 6 | 2 | 8 | 22 |
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.
~ 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.
Web APIs
Crypto
~ varied more than 10% run to run; read as approximate.
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.
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 6% apart (327 / 308), Node's are 3.2× apart (504 / 158): 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, 277 MB/s to 580. 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.
~ varied more than 10% run to run; read as approximate.
Hello world, measured externally
A framework hello-world (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.
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.
| Runtime | Burst req/s | 60s req/s | Change |
|---|---|---|---|
| Bun | 80.0k | 83.1k | +3.9% |
| Deno | 79.0k | 78.6k | -0.5% |
| esrun | 53.7k | 54.2k | +0.9% |
| Node.js | 32.7k | 33.6k | +2.7% |
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 into a buffer.
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.
~ 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.
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.
| 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 | exit 1 | graceful | graceful |
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.
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.
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.
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. esrun is level with Deno and ahead of Node and Bun on all three.
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.
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's number on that row is an outlier large enough to be worth checking against your own workload before trusting it either way.
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.
| Server | C=32 | C=64 | C=128 |
|---|---|---|---|
| Bun | 250,802 | 249,207 | 258,877 |
| Deno | 209,930 | 205,849 | 215,295 |
| esrun | 295,796 | 111,087 | 51,638 |
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.
| Client | C=32 | C=64 | C=128 |
|---|---|---|---|
| Bun | 247,822 | 248,388 | 248,798 |
| Deno | 250,012 | 260,502 | 250,123 |
| esrun | 253,512 | 252,172 | 265,192 |
| Node.js | 254,593 | 264,566 | 255,640 |
See the WebSocket guide.
HTTP/1.1 vs HTTP/2
The same hello-world server driven twice — once over HTTP/1.1, once over cleartext HTTP/2 (h2c) — so the only variable is the protocol version. 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.
| Runtime | Wide — 50 conns × 1 stream | Narrow — 1 conn × 50 streams | ||||
|---|---|---|---|---|---|---|
| HTTP/1.1 | HTTP/2 | Gain | HTTP/1.1 | HTTP/2 | Gain | |
| Bun | 88,858 | 38,627 | 0.43׆ | 30,436 | 50,196 | 1.65׆ |
| Deno | 90,018 | 21,826 | 0.24× | 30,388 | 37,586 | 1.24× |
| esrun | 50,392 | 40,406 | 0.80× | 18,697 | 66,308 | 3.55× |
| Node.js | 32,180 | 15,788 | 0.49׆ | 22,224 | 37,301 | 1.68׆ |
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.
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.
| 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 | 666 | 1030 | 886 |
serial_get — 5 000 GET, one at a time | 854 | 1040 | 884 | 645 | 1032 | 873 |
pipeline — 20 000 SET in one batch | 225 | 374 | 203 | 72 | 366 | 186 |
list — LRANGE over 50 000 elements | 158 | 203 | 119 | 28 | 209 | 111 |
hash — 200 × HGETALL of 1 000 fields | 957 | 479 | 496 | 205 | 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 | 33.6 | 124.4 | 99.6 |
serial_get | 38.8 | 112.9 | 79.6 | 34.0 | 123.5 | 99.2 |
pipeline | 123.8 | 181.1 | 129.1 | 44.3 | 191.6 | 139.5 |
list | 92.9 | 96.1 | 86.5 | 40.7 | 103.8 | 86.7 |
hash | 78.7 | 101.4 | 97.2 | 45.0 | 114.1 | 113.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.
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 8 ms against Node's 20 and Deno's 24.
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.
Reproduce
The harness lives in bench/. It auto-detects installed runtimes and can emit the JSON that powers 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
For CLI/script benchmarking, tools like hyperfine are also a good fit. Numbers vary by hardware; treat them as relative, not absolute.