Skip to content
Huseyin Babal
Go back

One Express app on Node 26, Bun 1.4 and Deno 2.9: a load test with the same node_modules

Two of the three big JavaScript runtimes changed owners within a year. Anthropic announced it was buying Bun in December 2025 and did not give a price. Cloudflare bought Deno Land in October 2026; its quarterly report puts the price at about $26.5 million in cash, and Deno’s announcement says the runtime gets one more year of fixes before development ends. Node.js stays with the OpenJS Foundation.

Most server code that could move between them is Express code, so that is what I tested: one Express 5 app, unchanged, on all three runtimes, on the same machine, against the same Postgres, with the same k6 load.

Everything is in a public repo: github.com/huseyinbabal/js-runtime-wars. It has the app, the Dockerfile, the droplet and setup scripts, the k6 script, all 36 k6 summary files, the Prometheus snapshot of the whole run with a Docker Compose file to open it in Grafana, and a checksum for every raw file.

Results

Medians of three rounds. Requests per second are k6’s http_reqs rate over each 3-minute test, latency is http_req_duration.

TestNode 26.13.1Bun 1.4.4Deno 2.9.8
Reads, req/s2,9644,0722,844
Writes, req/s3,6874,9853,525
Mixed, req/s3,0094,0812,910
No database, req/s7,18211,6925,944
Reads, p95 / p9943.8 / 52.4 ms33.6 / 41.0 ms51.2 / 62.4 ms
Writes, p95 / p9937.6 / 48.3 ms29.1 / 36.3 ms45.5 / 56.9 ms
Mixed, p95 / p9944.2 / 53.5 ms33.6 / 40.9 ms51.1 / 61.2 ms
No database, p95 / p9921.6 / 25.6 ms14.1 / 17.0 ms26.8 / 42.5 ms
RSS during reads148 MiB76 MiB146 MiB
Startup to first 2001.38 s1.23 s1.40 s

All 36 tests finished with 0 failed requests, 30.8 million requests in total.

With Postgres in the path, Bun handled 35–37% more requests than Node. Without it, 63% more. Deno trailed Node by 3–5% with the database and by 17% without it.

These numbers hold for one Express process with default settings on a 2 vCPU machine. They say nothing about Bun.serve, Deno.serve, other frameworks or the cluster module.

How stable the rounds were

Each runtime ran three rounds, and the order changed every round (node bun deno, bun deno node, deno node bun), so no runtime always went first on a cold database machine.

TestNode r1 / r2 / r3Bun r1 / r2 / r3Deno r1 / r2 / r3
Reads2,941 / 2,964 / 3,1034,072 / 4,045 / 4,3042,816 / 2,844 / 2,875
Writes3,628 / 3,733 / 3,6874,880 / 4,985 / 5,1553,500 / 3,525 / 3,616
Mixed2,943 / 3,119 / 3,0094,081 / 3,999 / 4,2162,854 / 2,910 / 2,937
No database7,153 / 7,271 / 7,18211,692 / 11,358 / 11,8025,944 / 5,926 / 5,973

The spread inside one runtime stayed under 7%. The gap between Bun and the other two is far larger than that. The gap between Node and Deno with the database is about the size of the round-to-round noise, so I would not read much into it. Without the database, Deno was slower than Node in all three rounds by 17–19%.

The app

A notes API in one file, server.js, with Express 5.3.0 and pg 8.23.1. The same table and index as the JVM test:

create table notes (
  id bigserial primary key,
  title text not null,
  body text not null,
  tag text not null,
  created_at timestamptz not null
);
create index notes_tag_id on notes (tag, id desc);

The routes the tests call:

const pool = new pg.Pool({ connectionString: process.env.DB_URL, max: 20 });
const app = express();
app.use(express.json());

app.get('/notes/:id', async (req, res) => {
  const { rows } = await pool.query('select id, title, body, tag, created_at from notes where id = $1', [req.params.id]);
  rows.length ? res.json(rows[0]) : res.sendStatus(404);
});

app.get('/notes', async (req, res) => {
  const { rows } = await pool.query('select id, title, body, tag, created_at from notes where tag = $1 order by id desc limit 20', [req.query.tag]);
  res.json(rows);
});

// no database: one note-shaped object per request, so only the runtime and Express are measured
app.get('/json', (req, res) => {
  const id = 1 + Math.floor(Math.random() * 200000), tag = TAGS[id % 8];
  res.json({ id, title: `Note ${id} about ${tag}`, body: 'Seeded note body for the JS test. '.repeat(4), tag, created_at: new Date().toISOString() });
});

POST /notes inserts and PUT /notes/:id updates, both with returning, so each write is one round trip.

Measuring the runtime itself

The app has its own small metrics port on :9091 with no extra package. It uses only what all three runtimes have: process.memoryUsage() and a timer.

// event loop delay: a 100 ms interval, and how late each tick fires
let lagSum = 0, lagMax = 0, lagN = 0, last = performance.now();
setInterval(() => {
  const now = performance.now(), d = Math.max(0, now - last - 100);
  lagSum += d; lagN++; lagMax = Math.max(lagMax, d); last = now;
}, 100);

When the loop is busy, the timer fires late, and the delay is that lateness. The same code runs on all three, so the numbers compare like for like. The port also reports RSS, used heap and the pg pool’s totalCount, idleCount and waitingCount. Prometheus scraped it every second.

One node_modules, three images

The packages are installed once, with npm, in a Node image. That folder is copied unchanged into all three runtime images:

ARG BASE
FROM node:26.13.1-trixie-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM ${BASE}
ARG RUNTIME
ENV RUNTIME=${RUNTIME}
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json server.js ./
EXPOSE 8080 9091

Only the base image and the start command differ:

node node:26.13.1-trixie-slim node server.js
bun oven/bun:1.4.4-debian bun server.js
deno denoland/deno:debian-2.9.8 deno run -A server.jsruntimes.txt

Bun and Deno run Express through their Node compatibility layers here. No bunx, no npm: specifiers, no Deno-specific imports. The point was to see what happens to an existing Node app when only the runtime changes.

The setup

Three CPU-Optimized droplets in London on one private network:

MachineSizeRuns
js-appc-2 (2 vCPU, 4 GB)the app container, node_exporter, cAdvisor
js-dbc-4 (4 vCPU, 8 GB)PostgreSQL 17.11, postgres_exporter
js-loadc-8 (8 vCPU, 16 GB)k6 2.4.0, Prometheus 2.45 (1 s scrape), Grafana

For each runtime in each round, run.sh on the load machine:

  1. Removes the previous container and drops the page cache on the app machine.
  2. Drops and recreates the notes table, inserts 200,000 rows with generate_series, runs vacuum analyze and a checkpoint.
  3. Starts the container and polls GET /notes?tag=js until it returns 200. That time is the startup number.
  4. Waits 30 seconds, then runs read, write, mixed and json for 3 minutes each, 100 virtual users, 15 seconds apart.
ModeRequests
read70% GET /notes/{id} (random id), 30% GET /notes?tag= (random of 8 tags, newest 20)
write50% POST /notes, 50% PUT /notes/{id}
mixed80% read mix, 20% write mix
jsonGET /json
export const options = {
  scenarios: { [MODE]: { executor: 'constant-vus', vus: 100, duration: '3m' } },
  tags: { runtime: __ENV.RT, round: __ENV.ROUND, mode: MODE },
  discardResponseBodies: true,
  summaryTrendStats: ['avg', 'p(50)', 'p(95)', 'p(99)', 'max'],
  // no `url` tag: every /notes/{id} would become its own Prometheus series
  systemTags: ['status', 'method', 'name', 'scenario', 'expected_response', 'check'],
};

k6 sends its metrics to Prometheus with remote write and writes a summary file per test with --summary-export. The request and latency numbers in this post come from those files. CPU, memory, event loop and pool numbers are averages over each test window (from 20 s after the start to the end) from Prometheus. analyze.py in the repo does both and writes summary.json.

Where the limit was

The app process used one full core in every test: container CPU between 96% and 104% of one core. The machine has two cores, and the whole machine sat at 60–65%.

Postgres was not the limit. Its machine CPU stayed between 15% and 37% in the database tests and at 3% in the no-database test.

The pool tells the same story. In the database tests about 50 requests were waiting for one of the 20 connections at any moment, on all three runtimes:

TestNodeBunDeno
Reads, requests waiting for a connection49.349.349.6
Writes50.951.246.2
Mixed49.949.649.6

With 100 users and 20 connections, some requests always wait for a connection. The queue was the same length on all three runtimes, so it doesn’t explain the gap between them. Bun pushed more requests through the same pool, and Postgres CPU went up with it (20% against 15% during reads).

Event loop and memory

TestNodeBunDeno
Event loop delay, reads2.9 ms2.5 ms3.7 ms
Event loop delay, no database6.4 ms4.4 ms8.4 ms
Used heap, reads28.1 MiB8.4 MiB28.9 MiB
RSS, reads148 MiB76 MiB146 MiB
RSS, no database156 MiB88 MiB146 MiB

The no-database test pushes the most requests through the loop, and the delay grew on all three. Deno had the largest delay and the lowest throughput in that test. This test can’t say which part of Deno’s Node layer costs the time. A CPU profile would.

Node and Deno run V8 and reported almost the same heap. Bun runs JavaScriptCore, so its heapUsed comes from a different engine. RSS is the number that compares across all three. Bun’s RSS was about half of the other two in every test.

Look at it yourself

The repo’s replay/ folder opens the Prometheus snapshot of the run:

git clone https://github.com/huseyinbabal/js-runtime-wars
cd js-runtime-wars/replay
tar xzf ../results/prom-snapshot.tgz -C data --strip-components=1
docker compose up -d
open http://127.0.0.1:13301

Set the time range to 2026-11-12 21:57 – 2026-11-13 00:02 UTC. The app dashboard has requests per second, latency, CPU, memory, event loop delay and the pool per runtime; the database dashboard has the Postgres machine.

To run the whole test again on your own DigitalOcean account, the README has the five commands. The run takes a little over two hours once the machines are set up.

What this doesn’t cover

Sources


Share this post:

Previous Post
One Spring Boot jar on five JVMs: Temurin, Corretto, Zulu, Semeru and GraalVM under load
Next Post
Redis 8.10 vs Valkey 9.1 under load: the same Go app, the same 4-core machine