Bun vs Node for an HTTP API: three routes, three answers
Advertisement
Runtime comparisons usually produce one number, and one number is how you end up believing something that is only true for one route. The same server, unchanged, answers three kinds of request here — and the gap between Node and Bun is 0%, 55% and 81% depending on which one you ask about.
The short answer
On identical node:http code, Bun 1.4 served a 200-row JSON response 81% faster
than Node 24 and a SHA-256 route 55% faster, both consistent across three rounds.
On a route that returns the string ok, the two were level — both saturated
around 54,000 requests a second on two CPUs. The win is in serialisation and
crypto, not in the HTTP layer.
What was run
One file, tools/bench/http/server.mjs, written against the
node:http API rather than Bun.serve.
That choice is the point: the question most teams have is what happens to the
service they already have, not what Bun can do with its own API.
Three routes, three shapes of work:
| Route | What it does | Bytes out |
|---|---|---|
/plain | responds with the string ok | 2 |
/json | builds and serialises a 200-row response | ~21 KB |
/hash | SHA-256 over 64 KB, then a small JSON reply | ~40 |
The load generator runs in its own container on a user-defined Docker network, so it never competes with the server for the same CPU allowance — the mistake that makes most laptop HTTP benchmarks measure the client. The load generator is autocannon, baked into its own image so that no package download lands inside a timed run. Fifty connections, eight seconds, three rounds, each runtime measured back to back inside the round.
- /plain · Node 2448,924
- /plain · Bun 1.452,856
- /json · Node 246,331
- /json · Bun 1.411,436
- /hash · Node 249,044
- /hash · Bun 1.414,027
- /plain · Node 24 — best round 54,416 — the two runtimes tie here
- /plain · Bun 1.4 — best round 54,220
- /json · Bun 1.4 — +81% median, +58% on best rounds
- /hash · Bun 1.4 — +55% median
The full picture, including the spread that decides how much of it to believe:
| Case | Median rps | Range across rounds | Spread | p99 |
|---|---|---|---|---|
/plain Node | 48,924 | 47,455 – 54,416 | 14.7% | 2 ms |
/plain Bun | 52,856 | 40,304 – 54,220 | 34.5% | 2 ms |
/json Node | 6,331 | 6,272 – 7,315 | 16.6% | 20 ms |
/json Bun | 11,436 | 11,079 – 11,593 | 4.6% | 9 ms |
/hash Node | 9,044 | 8,883 – 9,243 | 4.1% | 11 ms |
/hash Bun | 14,027 | 13,021 – 15,544 | 19.4% | 7 ms |
Read the spread column before the median. On /plain the ranges overlap almost
completely — Bun's best round was 54,220 and Node's was 54,416 — so the 8%
median gap is noise, and the honest answer is that both runtimes saturate two
CPUs at about the same point when there is no work to do. On /json the ranges
do not overlap at all, which is what a real difference looks like.
Why is JSON the route that separates them?
Because that route spends most of its time in the serialiser, and the serialiser
is the part of the two runtimes that differs most. The
measurements in the Node version comparison
found the same thing from the other direction: JSON.stringify was the one
workload where V8 versions differed by more than the noise.
A runtime benchmark with one route is a benchmark of whatever that route happened to spend its time on.
Bars are medians of three rounds; the short lines mark where the rounds
landed. Overlapping ranges are the reason /plain is reported as a tie.
What does an 81% gap mean for a real service?
Less than it sounds, because a real handler does not spend its whole time serialising. Put your own split in:
What the runtime is worth once your handler does real work
CPU time per request today
11.0 ms
45 ms total − 34 ms waiting
p50 if that CPU half gets 45% cheaper
40.1 ms
waiting stays at 34 ms; the rest × 0.55 (the measured /json gain)
Improvement in p50
11.0%
the runtime can only speed up the part that is not waiting
The 45% figure is the measured /json gain expressed as time saved (11,436 rps against 6,331 is 0.55× the time per request). Slide the waiting time up and the improvement collapses, which is why most services see far less than the headline.
A service whose handlers wait 34 ms on Postgres and spend 11 ms on CPU gets about a 12% improvement in p50 from a runtime that is 45% faster at the CPU part. That is real, and it is also smaller than one missing index.
So should you switch?
| Option | Measured effect | What it costs you | Pick it when |
|---|---|---|---|
| Stay on Node 24 LTSdefault | baseline | nothing | Your handlers are IO-bound, or anything in your stack uses native addons |
| Bun for JSON-heavy internal services | +81% rps on /json, p99 20 ms → 9 ms | a second runtime to operate | A service that mostly serialises large responses and has no native dependencies |
| Bun as a test and script runner only | not measured here | almost none — it is a dev dependency | You want the speed where it is safest: CI scripts and test runs, not production traffic |
| Bun.serve instead of node:http | not measured here | a rewrite, and portability back to Node | A new service where you accept the lock-in deliberately |
The honest reasons not to switch are not in the benchmark. They are native
addons compiled against Node's ABI, packages that reach into internals,
long-tail differences in node: module coverage, and the fact that your
on-call knowledge is Node-shaped. The
Bun documentation tracks which Node
APIs are implemented, and that page is a better input to the decision than any
requests-per-second figure.
What this benchmark does not cover
Cold start, memory under sustained load, behaviour past the point where the
event loop saturates, TLS termination, HTTP/2, and clustering across cores.
It also runs both runtimes single-process on two CPUs — most production Node runs
several processes behind a load balancer using
node:cluster, which changes the
arithmetic and is the first thing to try before changing runtime, as the
backend performance checklist
sets out.
And it says nothing about correctness. Identical code producing identical responses under both runtimes is exactly what this server was written to check, but "the three routes I tested behaved the same" is a narrow claim, and the interesting failures with an alternative runtime are never on the routes you thought to test.
Read the table like an engineer
3 questions — answers explained as you go.
1. On /plain, Bun medians 52,856 and Node 48,924. What is the finding?
2. Your handler spends 34 ms waiting on Postgres and 11 ms on CPU. What does a 45% cheaper CPU half buy?
3. Why does this benchmark use node:http under both runtimes instead of Bun.serve?
Frequently Asked Questions
Is Bun faster than Node in 2026?
For serialisation-heavy and crypto-heavy HTTP work, yes: measured on identical node:http code, Bun 1.4.2 served a 200-row JSON response 81% faster than Node 24.21 and a SHA-256 route 55% faster. On a trivial text response the two were level, both saturating at about 54,000 requests a second on two CPUs.
Does Bun run my existing Node server without changes?
This one did — the same node:http file ran under both runtimes with identical responses. Whether yours does depends on native addons and how deep into node: internals your dependencies reach; Bun publishes a compatibility page per module that is worth reading before you try.
Why is the plain-text route not faster under Bun?
Because there is nothing to be faster at. Two bytes out, no serialisation, no crypto — what is left is accept, parse, write, and both runtimes hit the same ceiling on the same two CPUs.
How much will my p50 improve if I switch?
Only the CPU part of a request changes. A handler at 45 ms p50 that spends 34 ms waiting on a database improves by roughly 12%, not 45%. Services that serialise large responses and wait on little else see far more.
How do I reproduce this?
tools/bench/http/run.sh in this repository starts the server container, waits for the port, runs autocannon from a second container on the same Docker network, and writes one JSON file per case. Run it three times and compare medians and ranges — a single round is not enough to tell 8% from noise.
The part that generalises
The three routes disagree, and that is the finding worth carrying to the next benchmark you read: a runtime is not fast or slow, a workload is. Before believing any comparison, ask which route it measured and whether the ranges overlapped — most published numbers answer neither.
For the workloads underneath these routes, the measured pieces are already here: what changed between Node versions covers the serialiser, worker threads cover moving CPU work off the main thread once a single runtime is not enough, and request context shows what instrumentation actually costs. If the service is slow for a reason that has nothing to do with the runtime — and it usually is — the profiling walkthrough and the regex that blocks the loop are the two places to look first.
Advertisement