benchmark2025-02-10Β·13Β·208/348

Testing Bun Runtime in Production: A Full-Stack Migration

Real-world experience migrating a Node.js API to Bun, including compatibility issues, performance benchmarks, and lessons from running Bun in production for three months.

Introduction

When Bun reached version 1.0 with claimed Node.js compatibility and 10x speed improvements, I was skeptical but curious. As a freelance developer who frequently builds APIs for clients, reducing infrastructure costs while maintaining compatibility would be a significant advantage. I decided to migrate a medium-sized Express API serving around 50,000 requests per day to Bun and document every issue I encountered.

After running Bun in production for three months, the results were mixed but mostly positive. This post covers the actual performance gains, the compatibility problems that emerged, and my current recommendation for when to use Bun versus Node.js.

Environment

# Development machine
uname -a
# Darwin MacBook-Pro.local 23.2.0 arm64

# Server environment
cat /etc/os-release
# Ubuntu 22.04.3 LTS

# Runtime versions
node --version
# v20.11.0

bun --version
# 1.0.28

# API framework
cat package.json | grep express
# "express": "^4.18.2"

The application architecture:

api-server/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ routes/
β”‚   β”‚   β”œβ”€β”€ users.ts
β”‚   β”‚   β”œβ”€β”€ orders.ts
β”‚   β”‚   └── products.ts
β”‚   β”œβ”€β”€ middleware/
β”‚   β”‚   β”œβ”€β”€ auth.ts
β”‚   β”‚   └── validation.ts
β”‚   β”œβ”€β”€ services/
β”‚   β”‚   β”œβ”€β”€ database.ts
β”‚   β”‚   └── cache.ts
β”‚   └── index.ts
β”œβ”€β”€ tests/
β”œβ”€β”€ package.json
└── tsconfig.json

Problem

The primary motivation was cost reduction. The API ran on a 4-vCPU DigitalOcean droplet costing $48/month, and during peak hours, response times exceeded 500ms. I wanted to determine if Bun could handle the same load on a cheaper $24/month instance while maintaining acceptable response times.

The secondary motivation was TypeScript. The current setup used ts-node for development and tsc for building, which added complexity to the development workflow. Bun's native TypeScript support would eliminate this friction.

The baseline performance metrics before migration:

# Load test with 100 concurrent connections for 30 seconds
hey -n 5000 -c 100 http://localhost:3000/api/users

# Results (Node.js 20)
# Requests/sec: 847.23
# Latency: avg=118ms p95=245ms p99=412ms
# Throughput: 1.23MB/sec

Analysis

Bun's speed comes from being built with Zig and using JavaScriptCore instead of V8. The built-in TypeScript transpiler, bundler, and test runner eliminate many tools that Node.js requires separately. However, the Node.js compatibility layer is not complete, and this caused issues with several npm packages.

The main compatibility issues I encountered fell into three categories:

  1. Native addon incompatibility: Packages using N-API or node-gyp had mixed results
  2. API differences: Some node:* module implementations differ subtly
  3. Package resolution: Bun's module resolution order occasionally differs from Node.js

Here is the error I encountered with the bcryptjs package:

// This import fails with Bun 1.0.28
import bcrypt from 'bcryptjs';

// Error: Cannot find module "bcryptjs" in the package.json "dependencies"
// The issue was with the package's exports field configuration

// Workaround: use bcrypt instead of bcryptjs
import bcrypt from 'bcrypt';

The database driver (pg) worked without issues, but the connection pooling behavior differed:

// With Node.js, this pool is shared across the process
import { Pool } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
});

// With Bun, the pool behavior is identical
// but the underlying socket handling differs
// This caused occasional ECONNRESET under high load

Solution

The migration happened in phases over two weeks.

Phase 1: Install Bun and verify basic compatibility:

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

# Verify the installation
bun --version

# Test if the application starts
bun run src/index.ts

# Run the existing test suite
bun test

Phase 2: Update package.json scripts:

{
  "name": "api-server",
  "scripts": {
    "dev": "bun --watch src/index.ts",
    "build": "bun build src/index.ts --outdir ./dist --target bun",
    "start": "bun run dist/index.js",
    "test": "bun test",
    "test:watch": "bun test --watch"
  }
}

Phase 3: Fix compatibility issues with native modules:

// Replace incompatible packages
// Before
import nodeCache from 'node-cache';

// After: use Bun's native cache or a compatible alternative
import { Database } from "bun:sqlite";

// For caching, use SQLite which Bun handles exceptionally well
const cache = new Database(":memory:");
cache.run("CREATE TABLE cache (key TEXT PRIMARY KEY, value TEXT, expires_at INTEGER)");

Phase 4: Configure production deployment with systemd:

# /etc/systemd/system/api-server.service
[Unit]
Description=API Server (Bun)
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/api-server
ExecStart=/home/deploy/.bun/bin/bun run dist/index.js
Restart=always
RestartSec=5
Environment=NODE_ENV=production
Environment=DATABASE_URL=postgres://user:pass@localhost:5432/db

[Install]
WantedBy=multi-user.target

Phase 5: Run production load tests on the Bun instance:

# Same load test parameters
hey -n 5000 -c 100 http://localhost:3000/api/users

# Results (Bun 1.0.28)
# Requests/sec: 2,341.87
# Latency: avg=42ms p95=89ms p99=156ms
# Throughput: 3.41MB/sec

The memory usage comparison:

# Node.js memory usage under load
ps aux | grep node
# USER   PID  %CPU %MEM  RSS
# deploy 1234 45.2 8.3   678MB

# Bun memory usage under same load
ps aux | grep bun
# USER   PID  %CPU %MEM  RSS
# deploy 5678 38.7 3.1   247MB

Lessons Learned

  1. Bun's SQLite integration is excellent. If your application uses SQLite for caching or sessions, Bun's bun:sqlite module outperforms any npm SQLite package.

  2. Not all npm packages work with Bun. Test thoroughly before migrating. The bun install compatibility mode helps, but native addons remain a risk area.

  3. Bun saves money on infrastructure. Moving from a 4-vCPU to a 2-vCPU instance saved $24/month while maintaining better performance than the original Node.js setup.

  4. The built-in test runner is fast but lacks some features of Vitest. If your test suite relies on advanced mocking or snapshot testing, verify compatibility first.

  5. Keep Node.js available as a fallback. I maintain a package.json script that runs the app with Node.js in case Bun-specific issues surface in production.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.