DevLift
Back to Blog

Bun vs Node.js: Runtime Speed vs Ecosystem Maturity

Bun is faster where the runtime layer is the bottleneck — startup, installs — and irrelevant where your database is. And the "native addons don't work" objection is out of date: Bun implements ~95% of N-API. How to make the call without repeating anyone's unsourced benchmark.

Admin
April 10, 202610 min read0 views

Bun vs Node.js: Runtime Speed vs Ecosystem Maturity

Your team is starting a new API service. Someone suggests using Bun. Another person says stick with Node — it's boring, it works, and every npm package supports it. The person pitching Bun pulls up a benchmark: 4× the HTTP throughput. The Node advocate counters: "that benchmark doesn't query a database." Both are right.

Both are also quoting numbers neither of them measured. That is the actual tension, and it is worth separating from the marketing: Bun is fast in ways that genuinely matter for some workloads and matter almost nothing for others. Node.js is stable in ways that are genuinely hard to replicate overnight. Here's how to make the call without cargo-culting either direction.

Quick Decision Matrix

Use Node.js when...Use Bun when...
Addons that reach into V8 internals, not just N-APIYou're starting a greenfield project
Maximum npm ecosystem compatibility requiredCold starts matter (Lambda, serverless)
Large existing codebase with many third-party depsTypeScript natively, no ts-node needed
Enterprise with strict runtime support requirementsMemory efficiency is a real constraint
Complex CI/CD pipeline already tuned to NodeYou want an all-in-one toolchain
You depend on a Bun-unimplemented built-inBuilding CLIs or dev tooling
You need Node LTS stability guaranteesPackage install speed is a bottleneck

The Engine Difference (And Why It Matters Less Than You Think)

Node.js runs on V8 — Google's JavaScript engine, the same one in Chrome. Bun runs on JavaScriptCore — Apple's engine from WebKit/Safari. Both are highly optimized JIT compilers. Both are extremely fast.

Bun itself is written in Zig, which is how it achieves its low startup times and memory footprint. Node.js is written in C++. The practical implication: Bun has less overhead at the runtime layer, which shows up most in cold starts and memory use, not in CPU-bound computation.

Rendering diagram...

About the "4× faster HTTP" number: every version of it I could trace led back to a synthetic test that parses headers and writes a fixed response, republished without its methodology. I am not going to repeat a throughput multiplier I did not measure. What is structurally true, and doesn't need a benchmark to believe, is where the runtime layer can possibly matter: startup, and memory. In a request that spends most of its time waiting on a database, the runtime is not the bottleneck and no runtime swap will make it one.

Deep Comparison

HTTP Performance: Synthetic vs Real

Synthetic HTTP benchmarks — return a JSON string, touch no database — do favour Bun, and by a wide margin. I am deliberately not quoting a figure, because the figures in circulation are mostly SEO reprints of each other with no reproducible harness attached, and a requests-per-second number without the hardware, the concurrency level, the payload size and the client is not information.

The reason it matters less than the number suggests is arithmetic you can do on your own service. Take your p50 and subtract your database time. Whatever is left is the ceiling on what a runtime swap can win you. For most CRUD APIs that remainder is small enough that the decision should be made on ergonomics, not throughput.

Where the runtime layer genuinely dominates is process startup, because there is no database in that path — it is all runtime initialisation. That is the one axis where Bun's Zig-and-JSC design has a structural rather than incidental advantage, and it is why the serverless and CLI cases below are the strongest ones for Bun. Measure it on your own function before you budget savings against it: hyperfine 'bun run noop.ts' 'node noop.js' takes a minute and tells you the truth about your machine, your runtime versions and your import graph.

TypeScript: The Single Best Reason to Try Bun

This is underrated. With Bun, you just run your TypeScript:

// api.ts — no compilation step, no ts-node, no tsx
import { Hono } from "hono";
 
const app = new Hono();
 
app.get("/users/:id", async (c) => {
  const userId = c.req.param("id");
  const user = await db.user.findUnique({ where: { id: userId } });
  return c.json(user);
});
 
export default app;
bun run api.ts  # just works

With Node.js, you need either ts-node, tsx, tsc --watch, or you compile first. Node 22+ added experimental TypeScript stripping, which is getting better, but it's still not as seamless. Bun's TypeScript support is native and production-grade.

Same story for JSX and TSX — both work out of the box without any Babel or transpilation config.

Built-in Tooling: One Binary to Rule Them All

Bun ships as a single binary that replaces several tools you'd normally install separately:

# Bun does all of this:
bun install          # package manager (substantially faster than npm)
bun run script.ts    # runtime with native TypeScript
bun test             # Jest-compatible test runner
bun build ./app.ts   # bundler with code splitting
 
# Node.js requires separate tools:
npm install          # slow
ts-node script.ts    # or tsx, or compile first
jest                 # separate install + config
webpack / vite       # separate install + config

Bun's package manager is genuinely faster, and unlike the HTTP numbers this is the one comparison you can settle for yourself in under five minutes: clear the caches, time bun install against time npm install on your own lockfile. Do that rather than trusting anyone's multiplier, including the ones in Bun's own marketing — the speedup depends heavily on cache state, dependency count and whether your packages run postinstall scripts.

The test runner is Jest-compatible, meaning your existing Jest tests usually run unmodified:

// tests/user.test.ts
import { describe, test, expect } from "bun:test";
import { createUser } from "../src/user";
 
describe("createUser", () => {
  test("rejects empty email", () => {
    expect(() => createUser({ email: "" })).toThrow("Invalid email");
  });
});
bun test             # runs, no extra config
bun test --watch     # watch mode
bun test --coverage  # coverage out of the box

Bun's Built-in APIs: Postgres, SQLite, S3

Bun 1.2 shipped Bun.SQL, a built-in PostgreSQL client. The interesting part is not a throughput claim — it's that there is no driver to install and no connection-pool library to choose:

import { sql } from "bun";
 
// Bun.SQL — built-in, no npm install
const users = await sql`
  SELECT id, email, created_at
  FROM users
  WHERE active = true
  ORDER BY created_at DESC
  LIMIT ${limit}
`;

Bun also ships bun:sqlite — a zero-dependency, fast SQLite driver. And a native S3 client. For apps that use these, it's meaningfully fewer dependencies and faster execution. For apps that don't, it's irrelevant.

Ecosystem Compatibility: The Real Risk

The usual framing here — "native addons don't work on Bun because it isn't V8" — is out of date, and it is worth correcting because it is the single most common reason teams rule Bun out. Bun's own compatibility documentation states that it "implements approximately 95% of the Node-API (N-API) interface" and that "most existing native addons built for Node.js work with Bun out of the box"; .node files can be required directly, or loaded through process.dlopen.

So the wall is narrower than the folklore. What actually breaks is an addon that reaches past N-API into V8's internals — the older nan-style addons — plus the built-in modules Bun hasn't implemented. node:v8's serialize/deserialize are a good example of the seam: they exist, but they use JavaScriptCore's wire format, so a payload serialized under Node will not deserialize under Bun.

The honest version of the compatibility risk is therefore not "native addons don't work" but "you have to check, and the check is cheap":

nan-based addons (compiled against V8 internals directly) → no path
node-sass  → deprecated everywhere; use dart-sass
node:repl, node:sqlite, node:trace_events → not implemented (use bun:sqlite)

Password hashing is the case that comes up most, and it is worth knowing you have good options regardless of how your addon audit goes:

// Option 1: Bun.password (built-in, bcrypt-compatible)
const bunHash = await Bun.password.hash("user-password", {
  algorithm: "bcrypt",
  cost: 10,
});
const isValid = await Bun.password.verify("user-password", bunHash);
 
// Option 2: bcryptjs (pure JS, slower, no addon to audit)
import bcrypt from "bcryptjs";
const jsHash = await bcrypt.hash("user-password", 10);

Before migrating a Node.js project to Bun, audit your package.json for packages that use node-gyp:

# find native addons in your dependency tree
find node_modules -name "binding.gyp" | head -20

If you find them, check whether there's a pure-JS alternative or a WASM version. If neither exists, Bun is the wrong choice for that project right now.

Memory Usage

Bun's resident set is generally smaller than an equivalent Node process, which follows from the same design difference as the startup time: a thinner runtime layer and a different heap manager. I am not putting megabyte figures on it, because the ones circulating are unattributed and because your number is dominated by your own workload — your connection pools, your caches, your dependency graph — far more than by the runtime.

If you are considering Bun specifically to fit more instances on the same hardware, this is the measurement to make before you commit, not after: run both under your real traffic shape and read /proc/<pid>/status or your orchestrator's own metrics. It is a two-afternoon experiment, and it is the only version of this number that should influence a capacity decision.

Deployment & Serverless

Bun works well for serverless, and this is the case where its advantage is least arguable: cold start is pure runtime initialisation with no I/O to hide behind. Measure your own function both ways — the number depends on your import graph as much as on the runtime — but the direction is not in doubt.

For containerized workloads, Bun's Alpine-based Docker image is compact:

FROM oven/bun:1-alpine AS base
WORKDIR /app
 
FROM base AS install
COPY package.json bun.lock ./   # bun.lock (text) since Bun 1.2; bun.lockb was the pre-1.2 binary format
RUN bun install --frozen-lockfile --production
 
FROM base AS runner
COPY --from=install /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]

Bun reads Node.js environment variables, loads .env files automatically (no dotenv install needed), and supports process.env the same way.

⚠️
Bun doesn't support --max-old-space-size or V8 heap flags. If you have tuned Node.js memory flags in your process manager or Dockerfile, those are no-ops on Bun. JavaScriptCore manages its own heap.

Node.js: Still Moving

Node isn't standing still, and several of Bun's original selling points have since been absorbed into it:

  • Experimental TypeScript stripping, so you can run .ts files without ts-node (landed in 22.6)
  • A built-in node:test runner with coverage support (available well before 22 — it stabilised across the 18 and 20 lines)
  • Native .env loading via --env-file (added in 20.6)
  • Stable --watch mode (stabilised in 22.0)

Note the versions, because they matter for the comparison: most of that list is not new. TypeScript support in Node is still narrower than Bun's — type stripping is not transformation, so no TSX and no decorators without a compile step — but the ergonomics gap is smaller than the framing usually suggests. Check which Node line you are actually targeting before you weigh this: by now 24 is the LTS to build against, with 22 in maintenance.

When to Use Bun

  • New greenfield project with no dependency on native addons — you get TypeScript, testing, and bundling in one tool with no config
  • Serverless workloads (AWS Lambda, Cloudflare Workers-style patterns) where cold starts directly affect cost and latency
  • CLI tools and dev tooling — Bun's startup speed makes CLIs feel snappy
  • Package manager only — even on Node.js projects, you can use Bun just as the package manager (bun install + bun run) and keep Node as the runtime
  • Memory-constrained environments — fitting more services on fewer instances matters

When to Use Node.js

  • Your project uses native addons (bcrypt, canvas, anything with node-gyp) and you can't swap them for JS equivalents
  • You're migrating a large existing codebase — not because N-API is missing, but because the surprises that remain are spread thinly across a big dependency graph and you have to find them one at a time
  • Your team needs LTS stability guarantees — Node has a predictable LTS schedule, long-term security patching, and years of production hardening
  • Complex CI/CD pipelines built around npm, Docker layers tuned for Node, and tooling that assumes Node internals
  • Maximum npm coverage — if you're installing hundreds of packages and can't audit them all for native addon usage, Node is safer

When to Use Both

The practical sweet spot for many teams: Node.js in production, Bun for package management and testing.

# .npmrc / bunfig.toml
# Use Bun for installs (fast) but stay on Node for now
// package.json
{
  "scripts": {
    "install": "bun install",
    "test": "bun test",
    "dev": "node --env-file=.env --watch src/index.mjs",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

This lets you capture 80% of Bun's practical benefits (faster installs, better test runner, TypeScript in tests) while keeping Node's battle-tested runtime in production. It's not an elegant architecture, but it's a pragmatic way to start adopting Bun incrementally.

The other common pattern: new services go on Bun, old services stay on Node until they need significant rework.

The Migration Path

If you're migrating an existing Node.js app to Bun, the actual steps are straightforward for most projects:

# 1. Install Bun
curl -fsSL https://bun.sh/install | bash
bun --version   # check what you actually got before trusting any doc, including this one
 
# 2. Install dependencies with Bun (generates bun.lock)
bun install
 
# 3. Run your app
bun run src/index.ts
 
# 4. Run your tests
bun test

Most Express.js apps work immediately. Where things break: native addons, __dirname / __filename quirks in ESM contexts, and some edge cases in stream behavior. The compatibility page in Bun's docs tracks known issues.

Before full migration, run bun test on your test suite. If tests pass, there's a good chance your app runs on Bun. Tests exercise your business logic and catch most compatibility issues without needing production traffic.

Where This Lands

Bun has crossed the threshold from "interesting experiment" to "legitimate production runtime." The package manager and test runner are genuinely better than their Node equivalents — faster, simpler, and with less config. The runtime speed advantage matters most for serverless and cold-start-sensitive workloads.

But "Bun everywhere" is still a bigger bet on a large existing project — not for the reason usually given. N-API is largely there; what you are exposed to is the long tail: an unimplemented built-in, a nan-era addon, a stream edge case. Any one of them is a small fix. Finding all of them across a few hundred transitive dependencies is the actual cost.

The default for new projects has shifted. If you're starting something fresh with no native addon requirements, Bun is now a reasonable default — not because of the benchmarks, but because of the ergonomics. TypeScript just works. One tool covers install, run, test, and build. The npm compatibility is high enough that you won't hit issues unless you go looking for them.

For existing Node.js projects, don't migrate just to migrate. Bun as the package manager is a no-risk win. Bun in production requires a compatibility audit first. The upgrade will pay off most if you have many instances (memory savings), serverless workloads (cold start savings), or a heavy test suite (test runner speed).

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read
Reading a 2 GB file all at once doesn't run out of memory — it hits V8's 512 MB string limit first. Streams process it in flat memory instead. Here's the pipeline pattern, custom Transform streams, and backpressure, with the numbers measured on Node 22.
AdminAugust 3, 20266 min read
Node.js runs on one CPU core by default. The cluster module lets you fork N workers across all cores — here's the pattern, graceful shutdown, IPC, and when PM2 makes it unnecessary.
AdminAugust 3, 20266 min read