Skip to content

Fix: Bun Not Working — Node.js Module Incompatible, Native Addon Fails, or bun test Errors

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Bun runtime issues — Node.js API compatibility, native addons (node-gyp), Bun.serve vs Node http, bun test differences from Jest, and common package incompatibilities.

The Problem

A Node.js package fails to load in Bun:

bun run server.ts
# error: Cannot find module 'bcrypt'
# Hint: native Node.js addons (node-gyp) are not supported in Bun

Or a package that works with Node doesn’t work with Bun:

bun run app.ts
# TypeError: dns.promises.lookup is not a function

Or bun test fails with tests that pass in Jest:

bun test
# error: Cannot use 'jest.mock()' — use 'mock.module()' instead

Or Bun.serve behaves differently than expected:

const server = Bun.serve({
  port: 3000,
  fetch(req) {
    return new Response('Hello');
  },
});
// Works, but WebSocket upgrade isn't handled

Why This Happens

Bun is not a drop-in replacement for Node.js in all cases. It is a distinct JavaScript runtime built on JavaScriptCore (the engine behind Safari), whereas Node.js and Deno both run on V8. JavaScriptCore has different optimization heuristics, slightly different ECMAScript timing semantics, and a different garbage collector. Most code does not notice, but anything that depends on V8-specific behaviour — v8.setFlagsFromString, --max-old-space-size, heap snapshots, or inspect protocols — does not exist on Bun.

The bigger compatibility gap is the Node API surface. Bun reimplements fs, http, crypto, stream, child_process, net, and tls in Zig, exposing them under the same names. That covers more than 90 percent of Node packages, but the long tail of edge cases (specific error codes from fs, exact byte sequences in HTTP responses, undocumented options on tls.createSecureContext) is where real applications break. Native addons are the hardest part: any package that ships a .node binary compiled against the V8 ABI cannot load in Bun at all, regardless of how well its JavaScript portion is written.

The test-runner story is similar. bun test is Jest-compatible in spirit but not API-identical. Bun ships its own assertion library, its own snapshot format, and its own mocking API. Tests that rely on Jest’s globals (jest.mock, jest.fn, jest.spyOn) need rewriting against import { mock, spyOn } from 'bun:test'. Treat Bun as a sibling runtime to Node rather than a faster fork, and the failure modes become predictable.

  • Native addons (.node files) are not supported — packages like bcrypt, sharp, canvas, and sqlite3 that use node-gyp to compile C++ extensions don’t work in Bun. Use pure-JavaScript or Bun-native alternatives.
  • Bun implements the Node.js API, not all of it — Bun tracks Node.js compatibility but some APIs are missing or behave differently. The Bun Node.js compatibility page documents what’s supported.
  • bun test is Jest-compatible but not identical — Bun’s test runner supports most Jest APIs but some mocking APIs differ. jest.mock() doesn’t exist; use mock.module().
  • Module resolution differences — Bun supports both CommonJS and ESM, but the resolution algorithm differs slightly from Node.js in edge cases.

Fix 1: Replace Native Addons with Pure-JS Alternatives

Native addons compiled with node-gyp don’t run in Bun. Use these alternatives:

# bcrypt → bcryptjs (pure JavaScript)
bun remove bcrypt
bun add bcryptjs

# Usage is identical
import bcrypt from 'bcryptjs';
const hash = await bcrypt.hash('password', 10);
const valid = await bcrypt.compare('password', hash);
# sharp (image processing) — actually works in Bun via the libvips bindings
# Try it: bun add sharp
# If it fails, use @squoosh/lib or jimp
bun add jimp  # Pure JS image processing

# canvas → use Bun's built-in canvas (Bun 1.1+) or @napi-rs/canvas
# @napi-rs/canvas supports Bun natively
bun add @napi-rs/canvas

# sqlite3 → use bun:sqlite (built-in, much faster)
import { Database } from 'bun:sqlite';
const db = new Database('mydb.sqlite');
db.run('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)');

Check if a package supports Bun:

# Try installing and running — Bun will warn about native modules
bun add package-name
bun run -e "import 'package-name'"

# Or check the package's GitHub for Bun compatibility notes
# https://bun.sh/guides has Bun-specific recommendations

Fix 2: Handle Node.js API Differences

Most Node.js APIs work in Bun, but some have gaps:

// Check Bun version and Node.js compat
console.log(Bun.version);       // e.g., "1.1.38"
console.log(process.version);   // Node.js compatibility version

// WORKS in Bun — common Node.js APIs
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { EventEmitter } from 'events';
import { Readable, Writable } from 'stream';

// ALSO WORKS — Bun implements these
import { createServer } from 'http';
import { createServer as createHttpsServer } from 'https';
import { Worker } from 'worker_threads';
import { createHash, randomBytes } from 'crypto';

// Bun-NATIVE alternatives (faster than Node.js equivalents)
// File I/O
const file = Bun.file('./data.json');
const text = await file.text();
const json = await file.json();
await Bun.write('./output.json', JSON.stringify(data));

// Hashing
const hash = Bun.CryptoHasher.hash('sha256', 'hello world', 'hex');

// Passwords
const hashed = await Bun.password.hash('mypassword');
const valid = await Bun.password.verify('mypassword', hashed);
// Uses argon2id by default (more secure than bcrypt)

Environment variables:

// Both work in Bun
process.env.MY_VAR       // Node.js style
Bun.env.MY_VAR           // Bun style — same values

// Bun auto-loads .env files — no dotenv needed!
// .env, .env.local, .env.production, .env.development
// (based on NODE_ENV value)

Fix 3: Use Bun.serve for HTTP

Bun.serve is Bun’s built-in HTTP server — significantly faster than Node’s http.createServer:

// Basic HTTP server
const server = Bun.serve({
  port: 3000,
  hostname: '0.0.0.0',  // Listen on all interfaces

  async fetch(req: Request) {
    const url = new URL(req.url);

    if (url.pathname === '/') {
      return new Response('Hello, Bun!');
    }

    if (url.pathname === '/api/users') {
      const users = await db.query.users.findMany();
      return Response.json(users);
    }

    return new Response('Not Found', { status: 404 });
  },

  error(error: Error) {
    return new Response(`Internal error: ${error.message}`, { status: 500 });
  },
});

console.log(`Listening on http://localhost:${server.port}`);

// WebSocket support
const wsServer = Bun.serve<{ userId: string }>({
  port: 3001,

  fetch(req, server) {
    const token = req.headers.get('authorization');
    const userId = verifyToken(token);

    if (!userId) return new Response('Unauthorized', { status: 401 });

    // Upgrade to WebSocket
    const upgraded = server.upgrade(req, { data: { userId } });
    if (upgraded) return;  // undefined on successful upgrade

    return new Response('Expected WebSocket', { status: 426 });
  },

  websocket: {
    open(ws) {
      console.log(`User ${ws.data.userId} connected`);
      ws.subscribe('broadcast');  // Pub/sub channel
    },
    message(ws, message) {
      ws.publish('broadcast', `${ws.data.userId}: ${message}`);
    },
    close(ws, code, reason) {
      console.log(`User ${ws.data.userId} disconnected`);
    },
  },
});

Use Elysia or Hono for routing:

// Elysia — designed for Bun (TypeScript-first, very fast)
import { Elysia, t } from 'elysia';

const app = new Elysia()
  .get('/', () => 'Hello Elysia')
  .post('/sign-in', ({ body }) => signIn(body), {
    body: t.Object({
      email: t.String({ format: 'email' }),
      password: t.String({ minLength: 8 }),
    }),
  })
  .listen(3000);

// Hono — works on Bun, Cloudflare Workers, Deno, Node.js
import { Hono } from 'hono';

const app = new Hono();
app.get('/', (c) => c.text('Hello Hono'));
app.post('/users', async (c) => {
  const body = await c.req.json();
  return c.json({ created: body });
});

export default {
  port: 3000,
  fetch: app.fetch,
};

Fix 4: Fix bun test Differences from Jest

Bun’s test runner is mostly Jest-compatible but has differences:

// bun test — differences from Jest
import { describe, test, expect, mock, beforeEach } from 'bun:test';

// WRONG — jest.mock doesn't exist in bun
jest.mock('./module');  // ReferenceError: jest is not defined

// CORRECT — use mock.module
mock.module('./module', () => ({
  fetchData: async () => ({ id: 1, name: 'Alice' }),
}));

// jest.fn() equivalent
const mockFn = mock(() => 'mocked value');
mockFn.mockReturnValue('new value');       // Same as jest.fn().mockReturnValue
mockFn.mockResolvedValue('async value');   // Same as jest.fn().mockResolvedValue
console.log(mockFn.mock.calls);           // Access call history

// jest.spyOn equivalent
import * as fs from 'fs/promises';
const spy = jest.spyOn(fs, 'readFile');  // DOESN'T EXIST in bun
// Instead:
const originalReadFile = fs.readFile;
fs.readFile = mock(() => 'mocked content');
// Restore:
fs.readFile = originalReadFile;

bun test configuration:

// bunfig.toml — Bun's config file
[test]
timeout = 10000          # Default test timeout (ms)
coverage = true          # Enable coverage
coverageThreshold = 80   # Minimum coverage %
// Timer mocks
import { describe, test, expect, jest } from 'bun:test';

test('timer test', () => {
  jest.useFakeTimers();  // Bun supports this

  const fn = mock();
  setTimeout(fn, 1000);

  jest.runAllTimers();
  expect(fn).toHaveBeenCalledTimes(1);

  jest.useRealTimers();
});

Fix 5: Bundling with Bun

Bun has a built-in bundler that replaces esbuild/webpack for many use cases:

// Bundle for production
const result = await Bun.build({
  entrypoints: ['./src/index.ts'],
  outdir: './dist',
  target: 'bun',         // 'bun' | 'node' | 'browser'
  format: 'esm',         // 'esm' | 'cjs' | 'iife'
  minify: true,
  sourcemap: 'external',
  splitting: true,        // Code splitting
  define: {
    'process.env.NODE_ENV': '"production"',
  },
});

if (!result.success) {
  console.error(result.logs);
  process.exit(1);
}
# CLI bundling
bun build ./src/index.ts --outdir ./dist --target bun --minify

# Bundle for browser
bun build ./src/client.ts --outdir ./public --target browser --splitting

# Bundle as a single executable
bun build ./src/index.ts --compile --outfile my-app
./my-app  # Runs without Bun installed

Fix 6: Migrate a Node.js Project to Bun

Step-by-step migration:

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

# 2. Install dependencies with Bun (reads package.json)
bun install  # ~25x faster than npm

# 3. Run the app with Bun
bun run src/index.ts  # Bun runs TypeScript natively — no tsc step!

# 4. Replace scripts in package.json
# Before: "dev": "ts-node src/index.ts"
# After:  "dev": "bun run src/index.ts"

# Before: "build": "tsc"
# After:  "build": "bun build src/index.ts --outdir dist"

# 5. Handle native module failures
# Replace: bcrypt → bcryptjs, sqlite3 → bun:sqlite, etc.

# 6. Migrate dotenv (not needed with Bun)
# Remove: import 'dotenv/config'
# Bun loads .env automatically

# 7. Run tests
bun test  # Jest-compatible — most tests work as-is

Performance comparison script:

# Check if Bun is faster for your specific workload
time node dist/index.js      # Node.js startup
time bun run src/index.ts    # Bun startup (TypeScript, no build step)

# HTTP benchmark
npx autocannon http://localhost:3000  # Against Node.js server
npx autocannon http://localhost:3001  # Against Bun.serve server

Bun vs Node.js vs Deno vs Cloudflare Workers

The “JavaScript runtime” decision is no longer just Node vs nothing. Four runtimes dominate, and each one fails differently. Picking the runtime that fits your workload removes most “Bun not working” tickets before they start.

Node.js (V8) is the baseline. It has the largest ecosystem, the most mature debugging story (node --inspect, Chrome DevTools, Clinic.js), and the broadest native-addon coverage. CommonJS is still the default for most npm packages, even ones that ship ESM, because of the long tail of Node 14/16/18 in production. Cold start on serverless is around 200-400 ms, and modules load synchronously which makes lazy-loading large dependency trees expensive.

Bun (JavaScriptCore) prioritizes startup speed, batteries-included tooling, and Web-Platform-API parity. Bun.serve, bun:sqlite, Bun.password, Bun.file, and the bundled bun build mean you rarely add a dependency for things Node would force you to npm-install. Cold start is sub-50 ms in most cases. The price is reduced compatibility with native addons and a smaller pool of tested third-party libraries.

Deno (V8) is the security-first runtime: every file, network, and env access is permissioned at the CLI level. Deno 2 brought back full npm compatibility, so it is no longer the “you cannot use anything from npm” runtime it was in 2020. ESM is the only module system, TypeScript runs natively, and the standard library is curated. Deploy targets are Deno Deploy (similar to Cloudflare Workers) and any container.

Cloudflare Workers (V8 isolates, not a full Node runtime) is a different beast. There is no fs, no child_process, no long-lived TCP sockets unless you use Hyperdrive or Durable Objects. The CPU-time budget per request is single-digit milliseconds, and memory is capped at 128 MB. Code must run inside a V8 isolate, which is why frameworks like Hono target Workers as a first-class platform and why Express does not run there at all. With the nodejs_compat flag, a polyfilled subset of Node APIs is available, but it is not a drop-in.

RuntimeEngineCold startNative addonsBundled toolingDeploy target
Node.jsV8200-400 msFull supportNone (npm-add)Anywhere
BunJavaScriptCore<50 msNot supportedBundler, test runner, sqliteAnywhere x86_64/ARM64
DenoV8~100 msLimited (FFI + selected)Bundler, test runner, fmt, lintAnywhere, Deno Deploy
Cloudflare WorkersV8 isolate<5 msNoneWrangler + Workers BuildCloudflare edge only

ESM/CJS interop highlights the differences sharply. Node treats require('esm-only-package') as a hard error and forces dynamic import(). Bun makes require() of an ESM package work as long as the module is statically analyzable. Deno rejects bare require entirely outside the node: compat layer. Workers reject both forms unless the bundle is built ahead of time. If a package “works in Bun but not in Node” or vice versa, the root cause is almost always ESM/CJS interop, not Bun being broken.

If your code targets multiple runtimes, lean on frameworks that abstract the differences. Hono runs on Node, Bun, Deno, and Workers from the same source. Drizzle ORM ships a driver per environment. Vitest works on Node and (via shim) on Bun. Tools that hard-code Node-only APIs — pm2, nodemon, node-cron — should be replaced with Bun --hot, deno run --watch, or the runtime’s own scheduler before you blame Bun for “not working.”

Still Not Working?

Bun.serve returns 500 for all routes — check the error handler. By default, Bun catches errors in fetch() and logs them, but if you have an error handler that doesn’t return a Response, the request hangs. Always return a Response from both fetch and error.

ESM/CJS interop issues — Bun handles both ESM and CommonJS, but mixed-module packages can cause issues. If you see Cannot use import statement in a CommonJS module, add "type": "module" to your package.json, or rename files to .mts/.mjs. For the opposite error, use createRequire from the module package.

Bun process exits immediately — unlike Node.js, Bun doesn’t keep the process alive if there’s no pending I/O or timers. If your app exits right after starting, ensure you have a persistent listener (e.g., Bun.serve, an interval, or an open database connection) to keep the event loop running.

Package version conflicts with Bun’s lockfile — Bun uses bun.lockb (binary format) instead of package-lock.json. If you switch between npm install and bun install, you may get version mismatches. Stick to one package manager and delete the other’s lockfile.

Workspaces and monorepos resolve to the wrong package — Bun supports workspaces in package.json but its hoisting rules differ from npm and pnpm. If a workspace package imports a sibling and gets Cannot find module, check whether the dependency is listed in the importer’s package.json (Bun is strict about this — no phantom hoisting). For pnpm-style isolation, prefer pnpm + Node; for hoist-everything DX, prefer Bun’s node_modules mode over the default isolated mode.

TypeScript decorators behave differently from tsc — Bun ships its own TS transformer and currently follows the TC39 Stage 3 decorator proposal, not the legacy experimentalDecorators flag that NestJS, TypeORM, and class-validator rely on. If a decorator-heavy framework throws Cannot read properties of undefined (reading 'metadata') under Bun but works under ts-node, you need to either run that part of the codebase on Node or wait for Bun to expand its legacy-decorator support. Check bunfig.toml for [transformers] decorators = "legacy" if your Bun version exposes the option.

fetch honours different DNS and proxy settings — Bun’s fetch uses its own HTTP client, not undici. HTTPS_PROXY, NO_PROXY, and NODE_EXTRA_CA_CERTS are recognized, but custom DNS resolvers configured via dns.setServers() in Node have no equivalent in Bun. If outbound HTTPS works on your laptop but fails inside a corporate proxy with Bun, set BUN_CONFIG_VERBOSE_FETCH=curl to get a curl-equivalent log line, then diagnose from there.

For related JavaScript runtime issues, see Fix: Node Uncaught Exception, Fix: Node.js Stream Error, Fix: esbuild Not Working, and Fix: Vite Env Variables Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles