Bun vs Node for an HTTP API: three routes, three answers

Sanjeev SharmaSanjeev Sharma
13 min read

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:

RouteWhat it doesBytes out
/plainresponds with the string ok2
/jsonbuilds and serialises a 200-row response~21 KB
/hashSHA-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.

Requests per second, median of three roundsrequests per second, higher is better
  • /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:

CaseMedian rpsRange across roundsSpreadp99
/plain Node48,92447,455 – 54,41614.7%2 ms
/plain Bun52,85640,304 – 54,22034.5%2 ms
/json Node6,3316,272 – 7,31516.6%20 ms
/json Bun11,43611,079 – 11,5934.6%9 ms
/hash Node9,0448,883 – 9,2434.1%11 ms
/hash Bun14,02713,021 – 15,54419.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.

Where the time goes on /json
1 / 5

A runtime benchmark with one route is a benchmark of whatever that route happened to spend its time on.

requests/second · bar is the median, line is the range across three rounds/plainranges overlap → no real difference/jsonno overlap → bun +81%/hashno overlap → bun +55%Node 24.21Bun 1.4.2

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?

OptionMeasured effectWhat it costs youPick it when
Stay on Node 24 LTSdefaultbaselinenothingYour 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 msa second runtime to operateA service that mostly serialises large responses and has no native dependencies
Bun as a test and script runner onlynot measured herealmost none — it is a dev dependencyYou want the speed where it is safest: CI scripts and test runs, not production traffic
Bun.serve instead of node:httpnot measured herea rewrite, and portability back to NodeA new service where you accept the lock-in deliberately
This benchmark deliberately says nothing about Bun.serve. Measuring Bun's own HTTP API against node:http would compare two different programs, and the result would be quoted as if it were this one.

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. 1. On /plain, Bun medians 52,856 and Node 48,924. What is the finding?

  2. 2. Your handler spends 34 ms waiting on Postgres and 11 ms on CPU. What does a 45% cheaper CPU half buy?

  3. 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading