Every few months a benchmark chart goes viral showing Bun crushing Node.js, and a client asks whether we should switch. I finally sat down and measured it properly on the same hardware, with the kind of application I actually deploy: a JSON API in front of Postgres. The answer is more interesting than the chart, and more useful.
The setup
- Hardware: Hetzner CPX31 (4 vCPU AMD, 8 GB), Ubuntu 24.04, nothing else running.
- Runtimes: Node.js 22.x LTS, Bun 1.2.x (current stable at the time of writing).
- App: a Hono API with three routes:
/health(no I/O),/orders/:id(one Postgres query viapg),/report(CPU-bound: aggregate 50k rows in memory). - Load:
ohaat 200 concurrent connections for 30 seconds, three runs each, median reported.
I used Hono because it runs unchanged on both runtimes, which removes framework differences from the comparison. Express numbers were similar in shape but lower on both.
The numbers
| Test | Node.js 22 | Bun 1.2 | Bun advantage |
|---|---|---|---|
bun install vs npm ci (cold, 214 deps) | 18.4 s | 2.1 s | 8.8x |
| Cold start to first response | 184 ms | 61 ms | 3.0x |
/health req/s | 52,300 | 128,900 | 2.5x |
/orders/:id req/s (Postgres) | 9,850 | 11,200 | 1.14x |
/orders/:id p99 latency | 41 ms | 37 ms | 1.1x |
/report req/s (CPU-bound) | 312 | 355 | 1.14x |
| Memory (RSS) under load | 142 MB | 118 MB | 1.2x |
Test suite (Vitest vs bun test, 640 tests) | 6.8 s | 1.9 s | 3.6x |
The pattern is clear. Where the runtime is the work (install, startup, parsing HTTP, running tests), Bun is much faster. Where the work is waiting on Postgres, the gap collapses to about 10-15%, because both runtimes spend most of the request idle waiting for the socket. The /orders route is what production looks like, and on a well-indexed database (see my indexing guide) that 10% is real but not transformative.
Compatibility: what actually broke
I ran four real codebases under Bun to see what happens. Two ran unchanged. The problems in the other two:
- Native addons.
bcrypt(the C++ one) failed to load;bcryptjsor Bun's built-inBun.passwordfixed it.sharpworked. An oldnode-sassdependency did not, but it should have been removed years ago anyway. - Streams edge cases. A file-upload handler using
busboybehaved differently around back-pressure. Replacing it with the Web Streamsrequest.formData()API worked on both runtimes and was less code. - Process signals and clustering. A graceful-shutdown script relied on
cluster. Bun does not implementnode:clusterthe same way; I switched to running multiple processes behind Nginx, which is how I run PHP-FPM pools anyway. - ORM/driver quirks. Prisma and Drizzle both worked.
pgworked. An old MySQL driver needed updating tomysql2.
None of these were hard, but each cost an hour of debugging in a codebase I did not write. Budget for that when quoting a migration.
Where Node caught up
A lot of "Bun is so much nicer" is about developer experience that Node 22+ now has:
# Node 22+: run TypeScript directly (type stripping), watch mode, built-in test runner, .env loading
node --experimental-strip-types --watch --env-file=.env src/server.ts
node --test tests/
# Bun: the same, no flags
bun --watch src/server.ts
bun test
Node also gained a stable fetch, WebSocket client, a permission model and a much faster startup than the Node 16 era that most complaints are based on. The gap in feel is smaller than the gap in benchmarks.
An incremental migration path
You do not have to decide everything at once. This is the order I recommend, and each step is independently reversible.
Step 1: Bun as package manager and test runner
Zero production risk, immediate CI wins. bun install reads package-lock.json and produces bun.lockb; your app still runs on Node.
# .github/workflows/ci.yml (excerpt)
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile # 2 s instead of 18 s
- run: bun test # or keep vitest: bunx vitest run
- run: bun run build
On the pipeline from my GitHub Actions guide this cut the PR check from 3m40s to 1m50s, mostly from install and test time.
Step 2: Bun runtime for new, isolated services
A new webhook receiver, a cron worker, an internal tool. Write it against Web-standard APIs (fetch, Request, Response, Web Streams) with Hono, and it will run on Bun, Node and Cloudflare Workers alike. That portability is worth more than the speed.
// src/server.ts -- runs on Bun, Node (via @hono/node-server) and Workers unchanged
import { Hono } from 'hono';
import { sql } from './db';
const app = new Hono();
app.get('/health', c => c.json({ ok: true, runtime: typeof Bun !== 'undefined' ? 'bun' : 'node' }));
app.get('/orders/:id', async c => {
const [order] = await sql`SELECT id, status, total_cents FROM orders WHERE id = ${c.req.param('id')}`;
return order ? c.json(order) : c.notFound();
});
export default app; // Bun: `bun run src/server.ts` serves it directly
// Node entry (src/node.ts):
// import { serve } from '@hono/node-server'; import app from './server'; serve({ fetch: app.fetch, port: 3000 });
Step 3: Migrate existing services only when a measurement says so
Profile first. If the service is I/O-bound (most are), the runtime swap buys 10% and costs a compatibility audit. If it is CPU-bound on JSON parsing, startup-sensitive (serverless, autoscaling), or has a huge test suite, the swap pays for itself quickly.
Docker images
Bun's smaller startup and single-binary nature make for lean images:
FROM oven/bun:1-alpine AS deps
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production
FROM oven/bun:1-alpine
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
USER bun
EXPOSE 3000
CMD ["bun", "run", "src/server.ts"] # no build step: Bun runs TS directly
Final image: 96 MB versus 168 MB for the equivalent node:22-alpine image with a build stage. The multi-stage pattern is the same one I use for PHP containers.
My verdict for 2026
- New services: Bun, written against Web-standard APIs so I am never locked in.
- Existing Node services that work: leave the runtime, adopt
bun installandbun testfor the CI speed. - Serverless and edge: the cold-start numbers make Bun (or Workers) the default.
- Anything with native addons or exotic streams: Node, until the audit says otherwise.
The honest summary: Bun is a genuinely faster runtime and a much better toolchain, and for most production APIs the database is still the thing to optimise first. Benchmark your own /orders, not /health. If you want help profiling a Node service before deciding, that is a short engagement I do often.