DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Bun 1.2 in Production: What Actually Broke When We Migrated from Node

Bun 1.2 in Production: What Actually Broke When We Migrated from Node

We migrated a mid-size TypeScript API (Next.js backend routes + a standalone Express-equivalent service) from Node 20 to Bun 1.2 over three weeks. The marketing copy says "drop-in replacement." Here's what that actually means in practice.

What We Were Running

  • A Next.js 14 app (stayed on Node — Next.js doesn't officially support Bun runtime yet for SSR)
  • A standalone API service: Hono + Drizzle ORM + Postgres
  • Several background workers: cron jobs, queue processors, file upload handlers
  • Jest test suite (~420 tests)

The standalone service and workers were the migration targets. Next.js stayed on Node.

The Wins Are Real

Let's get this out of the way: the performance improvement is not marketing fluff.

Cold start on our worker process dropped from ~1.8s to ~220ms. That's not a rounding error — it changes how you architect things. We moved several Lambda-style functions back to always-on Bun processes because the overhead model changed.

Install time with bun install vs npm install on a clean CI cache: 38 seconds down to 4. This alone pays for the migration pain on teams running 20+ PRs a day.

HTTP throughput on our Hono service improved ~40% under load testing (wrk, 12 threads, 400 connections). Bun's built-in HTTP server is genuinely faster than Node's.

What Actually Broke

1. node:crypto subtle differences

We used crypto.createCipheriv for encrypting webhook payloads before storing them. The code ran without errors under Bun but produced different ciphertext than Node for the same inputs. Turns out Bun's implementation had a subtle difference in how it handled IV padding in certain modes.

// This worked fine on Node, silently produced wrong output on Bun 1.1
// Bun 1.2 fixed it, but we wasted two days on this
import { createCipheriv, randomBytes } from 'node:crypto';

const iv = randomBytes(12); // GCM mode, 12 bytes
const cipher = createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const tag = cipher.getAuthTag(); // This was the problem — getAuthTag timing
Enter fullscreen mode Exit fullscreen mode

The fix: Bun 1.2 resolved this, but we added an integration test that round-trips encryption/decryption and cross-checks with a known Node-generated ciphertext before we deploy.

2. Jest Doesn't Run Under Bun

Bun has its own test runner (bun test), which uses a Jest-compatible API but is not Jest. If your test suite has:

  • jest.mock() with factory functions that reference require
  • Custom Jest reporters
  • jest-environment-jsdom
  • ts-jest transforms

You will have work to do. We had 420 tests. About 60 used jest.mock() patterns that didn't translate cleanly.

For the worker service (no DOM dependencies), migration was straightforward:

# Before
npx jest --testPathPattern=src/workers

# After
bun test src/workers
Enter fullscreen mode Exit fullscreen mode

For tests with complex mocking, we kept a parallel npm run test:node script and migrated test files incrementally. Don't try to do this in one PR.

3. Some npm Packages Still Use Native Node Addons

sharp (image processing), better-sqlite3, some PDF libraries — these ship .node binary addons compiled for Node's ABI. Bun has its own ABI. As of 1.2, Bun can run many .node addons via a compatibility layer, but it's not universal.

sharp worked. better-sqlite3 did not (we swapped to Bun's built-in SQLite: import { Database } from 'bun:sqlite' — it's faster anyway). One PDF generation library segfaulted silently and we had to replace it.

Pre-migration check:

# Find all native addons in your dependency tree
find node_modules -name '*.node' | sed 's|node_modules/||' | cut -d'/' -f1 | sort -u
Enter fullscreen mode Exit fullscreen mode

Test each one explicitly before committing to the migration.

4. Environment Variable Handling Is Different

Bun auto-loads .env files. Sounds convenient. It's a footgun if you've built your config loading to be explicit:

// You probably have something like this
import dotenv from 'dotenv';
dotenv.config({ path: '.env.production' });

// Under Bun, .env is already loaded BEFORE this line runs
// If .env and .env.production have the same key, .env wins
// because it loaded first
Enter fullscreen mode Exit fullscreen mode

We had a staging environment that was accidentally using production DB credentials for two days before we caught it. The fix is to explicitly pass --env-file to bun run and disable auto-loading:

bun --env-file=.env.production run src/server.ts
Enter fullscreen mode Exit fullscreen mode

5. __dirname and __filename in ESM

Bun encourages ESM. If you migrate and start using Bun-native patterns, __dirname stops working. The idiomatic Bun replacement:

// Node CJS
const configPath = path.join(__dirname, '../config/default.json');

// Bun / ESM
import { fileURLToPath } from 'node:url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const configPath = path.join(__dirname, '../config/default.json');

// Or Bun-specific (cleaner)
const configPath = new URL('../config/default.json', import.meta.url).pathname;
Enter fullscreen mode Exit fullscreen mode

This isn't a Bun bug — it's ESM spec behavior — but it'll hit you if you've been on CJS.

Migration Strategy That Worked

  1. Start with the test suite on a throwaway branch. Run bun test and see what breaks. This gives you a real count of work before you touch prod code.
  2. Migrate workers before the main API. Lower blast radius, easier to roll back.
  3. Keep a Node fallback in CI for 2 weeks. Run both bun test and node --experimental-vm-modules jest in CI. When bun test has full coverage, drop Jest.
  4. Audit native addons first. Non-negotiable. Do the find node_modules -name '*.node' check before writing a line of migration code.
  5. Pin Bun version in CI and Dockerfiles. bun@1.2.x not bun@latest. The project moves fast and minor versions have broken things between releases.

Verdict

For greenfield TypeScript services: start with Bun. The DX is better, installs are faster, and the runtime performance is real.

For migrations: the drop-in claim is ~80% true. The 20% will find you in production if you don't go looking for it first. The crypto behavior differences and native addon incompatibilities are the highest-risk items. Budget a week of engineering time per service, not an afternoon.

We're running 4 of 7 services on Bun in production as of this writing. The other 3 are blocked on native addon dependencies we haven't resolved yet.

Top comments (0)