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.3.12
- Deno deno 2.8.3 (stable, release, x86_64-unknown-linux-gnu)
- LLRT LLRT v0.8.0-beta (linux, x64)
- esrun esrun 0.9.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 |
|---|---|---|---|---|---|---|
| esrun | 44 | 12 | 17 | 5 | 9 | 1 |
| Bun | 44 | 14 | 8 | 17 | 4 | 1 |
| Node.js | 44 | 3 | 12 | 9 | 16 | 4 |
| Deno | 43 | 5 | 7 | 13 | 13 | 5 |
| LLRT | 26 | 10 | · | · | 1 | 15 |
Across 44 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) and peak resident set on a near-empty script.
Standard Workloads
Self-timed with performance.now() after an untimed JIT warmup; min of N interleaved runs, isolating engine cost from process launch.
Native vs Third-Party Parsers
Comparing runtimes running heavily optimized native parsing engines (esrun's runtime:serialization, LLRT's llrt:xml) versus JS-based fallback libraries (fast-xml-parser) for runtimes that lack native extensions (Node.js, Bun, Deno).
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.
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 |
|---|---|---|---|
| esrun | 277,966 | 114,884 | 49,956 |
| Bun | 209,342 | 213,263 | 218,167 |
| Deno | 180,226 | 182,714 | 182,129 |
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 |
|---|---|---|---|
| esrun | 218,485 | 223,241 | 222,020 |
| Bun | 211,353 | 216,538 | 216,162 |
| Deno | 215,337 | 221,214 | 218,723 |
| Node.js | 220,930 | 223,566 | 223,664 |
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 | |
| esrun | 66,939 | 53,080 | 0.79× | 20,157 | 73,541 | 3.65× |
| Bun | 119,785 | 43,086 | 0.36׆ | 31,109 | 49,142 | 1.58׆ |
| Deno | 115,141 | 27,409 | 0.24× | 32,303 | 39,209 | 1.21× |
| Node.js | 36,597 | 18,413 | 0.50׆ | 23,221 | 39,700 | 1.71׆ |
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.
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 # regenerate the data module this page renders bench/gen-bench-data.sh # 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.