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 via pg), /report (CPU-bound: aggregate 50k rows in memory).
  • Load: oha at 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

TestNode.js 22Bun 1.2Bun advantage
bun install vs npm ci (cold, 214 deps)18.4 s2.1 s8.8x
Cold start to first response184 ms61 ms3.0x
/health req/s52,300128,9002.5x
/orders/:id req/s (Postgres)9,85011,2001.14x
/orders/:id p99 latency41 ms37 ms1.1x
/report req/s (CPU-bound)3123551.14x
Memory (RSS) under load142 MB118 MB1.2x
Test suite (Vitest vs bun test, 640 tests)6.8 s1.9 s3.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.

Where time goes in a typical /orders request (Node, p50 ≈ 9.8 ms) runtime + framework ▏█▏ 0.4 ms ← the part Bun makes 2x faster TCP + TLS to Postgres ▏███▏ 1.1 ms query execution ▏██████████████████▏ 7.1 ms ← the part an index makes 10x faster JSON serialisation ▏██▏ 0.6 ms network to client ▏██▏ 0.6 ms

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; bcryptjs or Bun's built-in Bun.password fixed it. sharp worked. An old node-sass dependency did not, but it should have been removed years ago anyway.
  • Streams edge cases. A file-upload handler using busboy behaved differently around back-pressure. Replacing it with the Web Streams request.formData() API worked on both runtimes and was less code.
  • Process signals and clustering. A graceful-shutdown script relied on cluster. Bun does not implement node:cluster the 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. pg worked. An old MySQL driver needed updating to mysql2.

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 install and bun test for 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.