Bun and Deno Crush Node — Until the Work Gets Real
I wanted a real answer to "how much traffic can a JavaScript backend actually handle," not a marketing chart. So I built a small Docker-based rig: four services — Express, Fastify (both Node), Bun's native server, and Deno's native server — each capped at 1 CPU core and 512MB RAM, benchmarked one at a time with k6 so they never compete for host resources.
Same runtime family (Node vs Bun vs Deno, not just Express vs Koa) so the gaps would actually be visible, same hardware ceiling for every service, and a load profile that ramps concurrency until something gives instead of stopping at a comfortable number.
Round 1: a trivial endpoint
First pass: each service returns a bare {"status":"ok"}
JSON response — no database, no real logic, just isolating HTTP-layer
overhead. k6 ramped from 0 to 10,000 virtual users.
| Service | Avg req/s | Peak req/s | p95 latency |
|---|---|---|---|
| Express | 5,709 | 7,087 | 177ms |
| Fastify | 17,204 | 23,850 | 114ms |
| Bun | 33,245 | 52,567 | 186ms |
| Deno | 34,803 | 47,112 | 239ms |
Bun and Deno sit in a clear tier above the Node frameworks — roughly 2-6x the throughput — which tracks: their native HTTP servers skip Node's request-handling layers entirely. Fastify also comfortably beats Express at ~3x the throughput and noticeably lower p95, inside the same runtime. Framework choice clearly matters here.
None of the four actually broke under this load — failure rates stayed at or near 0% all the way to 10,000 VUs. They plateaued instead of erroring out, so "breaking point" for this round is really about throughput ceiling, not crashes.
Round 2: give it something to actually compute
A bare JSON response mostly measures the HTTP layer, not the runtime doing real work. So I changed the handler in all four services to synchronously count primes below 100,000 by trial division — identical code in every service, about 12-13ms of pure compute per request once JIT-warmed, measured almost identically across V8 (Node, Deno) and JavaScriptCore (Bun).
That number matters: a CPU-bound, single-threaded handler on a 1-core container can only do so much work per second no matter how efficient its HTTP layer is. So the load profile dropped from 10,000 VUs down to 1,000 — the point wasn't peak throughput anymore, it was what happens once concurrency exceeds a hard compute ceiling.
| Service | Avg req/s | Median latency | p95 latency | Max latency |
|---|---|---|---|---|
| Express | 73.3 | 1,033ms | 35,928ms | 53,716ms |
| Fastify | 73.7 | 1,042ms | 35,947ms | 53,669ms |
| Bun | 72.4 | 1,556ms | 13,710ms | 15,175ms |
| Deno | 78.5 | 1,522ms | 12,235ms | 12,912ms |
This flips the story from round one on its head.
Throughput converges. All four land in the same 72-88 req/s band. The moment the handler is genuinely CPU-bound, the framework/HTTP-layer differences that gave Bun and Deno a 5-6x lead over Express basically vanish — the ceiling is now single-core JS execution speed, and V8 and JavaScriptCore turn out to be very close for this kind of workload.
But the shape of the latency distribution does not converge. Median latency is actually a bit worse for Bun and Deno (~1.5s vs ~1.0s for the Node frameworks). Yet their p95 and max are 3-4x tighter — Bun and Deno cap out around 13-15 seconds, while Express and Fastify's tail stretches to 35-54 seconds. Node's request queue lets a minority of requests wait dramatically longer than the median once demand outstrips capacity; Bun and Deno's scheduling looked more evenly distributed across every run.
Every request still succeeded — 0% failures in this round too. The queue just kept growing instead of shedding load, which is its own lesson: a service can look "up" on every health check while some fraction of requests wait a minute for a response.
Framework choice mattered a lot for I/O-bound work and barely at all for CPU-bound work. What kept mattering, in a completely different way, was how each runtime handled a backlog once it couldn't keep up.
Round 3: finding the actual breaking point
Round two never technically failed — 0% errors even as latency crept into the tens of seconds. So I pushed harder: same CPU-bound handler, but the ramp now goes to 6,000 VUs with an explicit 60-second request timeout, on the theory that a deep enough queue would finally exceed that timeout and produce real failures instead of just a slow plateau.
| Service | Failed rate | Median latency | p90 latency | Aborted at |
|---|---|---|---|---|
| Express | 21.0% | 1,313ms | 60,000ms | ~2,851 VUs |
| Fastify | 20.9% | 1,296ms | 60,000ms | ~4,170 VUs |
| Bun | 20.5% | 13,363ms | 60,000ms | ~1,465 VUs |
| Deno | 20.1% | 29,864ms | 60,001ms | ~1,427 VUs, full ramp |
This time all four tripped the failure threshold right around 20-21% — a real breaking point. p90 latency for every service pins at the 60-second timeout itself, since a large share of requests genuinely timed out.
And the latency story from round two completely reverses. At 1,000 VUs, Bun and Deno had the better tail latency. At 6,000 VUs, their median latency is dramatically worse — Bun at 13.4 seconds, Deno at a striking 29.9 seconds — while Express and Fastify's median stayed fast (~1.3s) even as their upper half blew out to the timeout ceiling.
A plausible read on this (not fully confirmed — proving it would need connection-level tracing, not just HTTP metrics): Express and Fastify's requests seem to split cleanly into a fast lane and a timed-out lane, which keeps the fast half's median low. Bun and Deno look like they let most in-flight requests age together in one shared backlog instead. Deno actually survived the entire ramp to 6,000 VUs and into ramp-down — longer than any other service — while still posting the worst median, which fits a picture of it accepting and holding far more concurrent requests than it could promptly finish, rather than shedding load early the way Node appears to.
Worth stating plainly: whichever runtime "handles overload better" depends entirely on how far past capacity you push it. Bun and Deno's advantage at moderate overload flipped into a disadvantage at severe overload.
Two things worth calling out about the setup
Neither service actually throttled from heat — CPU package temperature topped out at 73.6°C in the busiest run (trivial endpoint, 10,000 VUs), comfortably under the throttle point, and clock speed held steady around 4.0-4.1GHz throughout. Good to confirm directly rather than assume, since a thermal dip would have looked identical to a framework limit on a throughput chart.
The more interesting gotcha was on the load-generator side, not the frameworks: k6's raw per-request CSV logging buffers the entire run in memory before flushing. At the trivial endpoint's peak (Bun, ~33k req/s average), that pushed host RAM to ~14 of 15GB used plus ~11GB swapped — the load generator itself came close to becoming the bottleneck being measured. Worth knowing before trusting any local benchmark's numbers at face value.
Takeaway
If you're picking a runtime for a service that's mostly I/O — proxying, thin API layers, database-bound routes — Bun and Deno's native HTTP servers have a real, large edge over Node in this test. If your service does meaningful synchronous compute per request, that edge mostly disappears, and "which is faster" stops being a single answer at all.
At moderate overload, Bun and Deno degraded more gracefully than Node. Push further past their actual breaking point, and that reverses completely. Neither "Bun and Deno are just better" nor "it doesn't matter once it's CPU-bound" survives all three rounds of this test — the honest answer is that it depends on exactly how far past capacity you are, which is a less satisfying conclusion than a clean ranking, but a more useful one.