Published on

Migrating From Node.js to Bun — What Works, What Breaks, and Benchmarks

Authors

Introduction

Bun claims Node.js compatibility, but the reality is nuanced. Some apps migrate with zero changes; others hit friction with native modules or ESM edge cases. This guide covers compatibility status, performance benchmarks, and a safe migration strategy for production codebases.

Bun's Node.js Compatibility Layer

Bun ships with a Node.js compatibility layer via the node: protocol:

// Works on Bun and Node
import * as fs from 'node:fs'
import * as path from 'node:path'
import * as crypto from 'node:crypto'

console.log(fs.readFileSync('file.txt', 'utf-8'))

Core modules like fs, path, crypto, http, and stream are fully implemented. However, some modules have partial support or behavioral differences.

Fully Supported: fs, path, crypto, util, buffer, events, stream, zlib, http, https, net, dgram, dns

Partial Support: cluster (no clustering, runs single process), child_process (limited), net (some options missing)

Unsupported: v8, vm (some APIs), worker_threads (different implementation)

Package Management Speed

bun install is dramatically faster than npm or yarn:

# npm install: ~45s (with 200 dependencies)
time npm install

# yarn install: ~38s
time yarn install

# bun install: ~2.5s
time bun install

Bun parallelizes all operations and reuses cached packages. On monorepos with 50+ packages, the difference is staggering.

Lock file format:

# bun.lock
[[packages]]
name = "lodash"
version = "4.17.21"
resolution = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#..."

Bun's lock file is deterministic and human-readable.

HTTP Servers: Bun.serve() vs Express

Bun provides a native HTTP server significantly faster than Express:

// Bun native — 65,000 req/s
export default {
  fetch(request: Request) {
    return new Response('Hello Bun')
  },
  port: 3000,
}

// Express on Node — 12,000 req/s
import express from 'express'
const app = express()
app.get('/', (req, res) => res.send('Hello Express'))
app.listen(3000)

Bun.serve() uses no external dependencies. The HTTP parser is built into the runtime.

Native Database Drivers

Bun ships with native drivers for SQLite and PostgreSQL:

import { Database } from 'bun:sqlite'

const db = new Database('data.db')
db.prepare('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)').run()

const stmt = db.prepare('INSERT INTO users (name) VALUES (?)')
stmt.run('Alice')

const users = db.prepare('SELECT * FROM users').all()
console.log(users)

Direct SQLite queries execute in <1ms. No serialization layer like in Node.js ORMs.

PostgreSQL:

const client = await Bun.sql`SELECT version()`
const result = await client.rows()
console.log(result)

Native drivers are opt-in. If you''re using an ORM, this matters less.

Built-In Test Runner

Bun includes a test runner—no Jest config needed:

import { describe, it, expect } from 'bun:test'

describe('Math', () => {
  it('adds numbers', () => {
    expect(1 + 1).toBe(2)
  })
})

Run with bun test. Tests execute in parallel at native speed.

What Doesn't Work Yet

Native C++ Modules: Node's node-gyp modules (like bcrypt, canvas, sqlite3) don't work in Bun. Use pure JavaScript alternatives:

  • bcrypt@noble/hashes or argon2
  • sqlite3 → use bun:sqlite instead
  • canvas → no direct substitute yet

ESM Edge Cases: Default exports from CJS packages sometimes fail:

// This works
import pino from 'pino'

// This sometimes breaks (check Bun docs)
import * as pino from 'pino'

Certain Node APIs: Worker threads, vm.Script, and cluster mode have limitations.

Performance Benchmarks

Throughput (req/s, 12 connections):

FrameworkRuntimeThroughputStartup
NativeBun65,000<5ms
ElysiaBun64,000<10ms
HonoBun62,000<8ms
FastifyNode 2228,000150ms
ExpressNode 2212,000180ms

Memory usage (idle):

  • Bun runtime: 25MB
  • Elysia API: 20-30MB
  • Node.js runtime: 35MB
  • Express API: 60-90MB

Bun''s garbage collector is aggressive—it reclaims memory faster than Node.

Gradual Migration Strategy

Don't rip-and-replace. Migrate incrementally:

Phase 1: Audit Dependencies

# Check which packages are Bun-compatible
npm ls --all | grep gyp  # Find native modules

Replace problematic packages before migrating. For example:

  • bcrypt@noble/bcrypt
  • uuid → Bun''s built-in crypto.randomUUID()

Phase 2: Local Testing

# Install Bun
curl -fsSL https://bun.sh/install | bash

# Install and test locally
bun install
bun run src/index.ts

Test your app thoroughly. Run your full test suite:

bun test

Phase 3: Containerization

Use Bun''s official Docker image:

FROM oven/bun:1.0

WORKDIR /app
COPY . .

RUN bun install --production
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]

Build and test in staging before production.

Phase 4: Canary Deployment

Deploy to 5-10% of traffic first. Monitor:

  • Error rates
  • Latency percentiles (p50, p95, p99)
  • Memory usage
  • CPU usage

Only ramp up after 2-4 hours of stable metrics.

Compatibility Layer for Express

If you''re running Express on Bun, create an adapter:

import express from 'express'

const app = express()
app.use(express.json())

app.get('/api/users', (req, res) => {
  res.json([{ id: 1, name: 'Alice' }])
})

// For Bun
export default {
  async fetch(req: Request) {
    // Wrap Express to handle Bun's Request/Response
    return new Promise((resolve) => {
      const nodeReq = new IncomingMessage(/* ... */)
      const nodeRes = new ServerResponse(nodeReq)
      app(nodeReq, nodeRes)
      nodeRes.on('finish', () => {
        resolve(new Response(nodeRes.body))
      })
    })
  },
  port: 3000,
}

This is the fallback if native Bun adapters aren''t available.

Production Readiness Checklist

  • All dependencies are Bun-compatible or have replacements
  • Full test suite passes on Bun
  • Benchmarks meet SLA thresholds
  • Error handling is identical to Node.js version
  • Database drivers are configured (native or ORM)
  • Logging captures all errors
  • Health checks pass
  • Graceful shutdown works
  • Monitoring and tracing are configured
  • Rollback plan is documented

When to Migrate to Bun

Migrate if: You''re greenfielding a new API, performance is critical, and your stack is modern (TypeScript, no native modules).

Stay with Node.js if: You have large monoliths, heavy native module usage (bcrypt, canvas), or your team lacks experience with Bun.

Bun is production-ready for new projects. Migration of existing systems is lower-risk if they''re already modern.

Checklist

  • Check Bun compatibility of all dependencies
  • Audit native module usage
  • Run test suite locally on Bun
  • Benchmark your app on both runtimes
  • Plan canary deployment strategy
  • Configure observability and monitoring
  • Test graceful shutdown and error handling
  • Document rollback procedure

Conclusion

Bun is not Node.js, but it''s Node-compatible enough for most applications. The performance gains are real—2-5x throughput and sub-10ms startup times are achievable. For new projects, Bun should be your default. For existing systems, migrate only if the pain of native modules is acceptable. The ecosystem is maturing rapidly; within 12 months, Bun compatibility will be the norm, not the exception.