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.
| Test | Node 26.13.1 | Bun 1.4.4 | Deno 2.9.8 |
|---|---|---|---|
| Reads, req/s | 2,964 | 4,072 | 2,844 |
| Writes, req/s | 3,687 | 4,985 | 3,525 |
| Mixed, req/s | 3,009 | 4,081 | 2,910 |
| No database, req/s | 7,182 | 11,692 | 5,944 |
| Reads, p95 / p99 | 43.8 / 52.4 ms | 33.6 / 41.0 ms | 51.2 / 62.4 ms |
| Writes, p95 / p99 | 37.6 / 48.3 ms | 29.1 / 36.3 ms | 45.5 / 56.9 ms |
| Mixed, p95 / p99 | 44.2 / 53.5 ms | 33.6 / 40.9 ms | 51.1 / 61.2 ms |
| No database, p95 / p99 | 21.6 / 25.6 ms | 14.1 / 17.0 ms | 26.8 / 42.5 ms |
| RSS during reads | 148 MiB | 76 MiB | 146 MiB |
| Startup to first 200 | 1.38 s | 1.23 s | 1.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.
| Test | Node r1 / r2 / r3 | Bun r1 / r2 / r3 | Deno r1 / r2 / r3 |
|---|---|---|---|
| Reads | 2,941 / 2,964 / 3,103 | 4,072 / 4,045 / 4,304 | 2,816 / 2,844 / 2,875 |
| Writes | 3,628 / 3,733 / 3,687 | 4,880 / 4,985 / 5,155 | 3,500 / 3,525 / 3,616 |
| Mixed | 2,943 / 3,119 / 3,009 | 4,081 / 3,999 / 4,216 | 2,854 / 2,910 / 2,937 |
| No database | 7,153 / 7,271 / 7,182 | 11,692 / 11,358 / 11,802 | 5,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:
| Machine | Size | Runs |
|---|---|---|
| js-app | c-2 (2 vCPU, 4 GB) | the app container, node_exporter, cAdvisor |
| js-db | c-4 (4 vCPU, 8 GB) | PostgreSQL 17.11, postgres_exporter |
| js-load | c-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:
- Removes the previous container and drops the page cache on the app machine.
- Drops and recreates the
notestable, inserts 200,000 rows withgenerate_series, runsvacuum analyzeand a checkpoint. - Starts the container and polls
GET /notes?tag=jsuntil it returns 200. That time is the startup number. - Waits 30 seconds, then runs read, write, mixed and json for 3 minutes each, 100 virtual users, 15 seconds apart.
| Mode | Requests |
|---|---|
| read | 70% GET /notes/{id} (random id), 30% GET /notes?tag= (random of 8 tags, newest 20) |
| write | 50% POST /notes, 50% PUT /notes/{id} |
| mixed | 80% read mix, 20% write mix |
| json | GET /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:
| Test | Node | Bun | Deno |
|---|---|---|---|
| Reads, requests waiting for a connection | 49.3 | 49.3 | 49.6 |
| Writes | 50.9 | 51.2 | 46.2 |
| Mixed | 49.9 | 49.6 | 49.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
| Test | Node | Bun | Deno |
|---|---|---|---|
| Event loop delay, reads | 2.9 ms | 2.5 ms | 3.7 ms |
| Event loop delay, no database | 6.4 ms | 4.4 ms | 8.4 ms |
| Used heap, reads | 28.1 MiB | 8.4 MiB | 28.9 MiB |
| RSS, reads | 148 MiB | 76 MiB | 146 MiB |
| RSS, no database | 156 MiB | 88 MiB | 146 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
Bun.serveandDeno.serve. The native servers skip the Nodehttplayer and Express, so they need their own test.- Other frameworks (Hono, Fastify, Elysia), and other database drivers.
pgis pure JavaScript here; Bun has its own SQL client. - The cluster module or several processes per machine. All three runtimes used one core, and the second core had room left.
- Bigger machines, TLS, longer runs, cold starts in serverless.