What Bun and Node.js are, and how they differ under the hood

Node.js embeds Google's V8 engine in a C++ runtime built around libuv's event loop. Bun embeds Apple's JavaScriptCore (JSC), the engine behind Safari, inside a runtime written largely in Zig. Both run JavaScript and expose most of the same Node APIs. Bun also folds into a single binary several tools that a Node project usually assembles separately.

Teams consider Bun as a Node.js alternative for three main reasons. Startup is faster. Fewer tools need configuring. TypeScript runs directly without a build step. That last point needs care: Bun strips types during transpilation but does not type-check, so you still need tsc in CI.

The engines differ in ways that show up in production. V8 compiles through Ignition, Sparkplug, Maglev and TurboFan. JSC uses LLInt, Baseline, DFG and FTL. Each engine decides differently when code is hot enough to optimize, so warm-up curves and steady-state throughput differ even for identical code.

Garbage collection also differs. V8 uses a generational collector with a separate young-generation scavenger. JSC's Riptide collector is concurrent and non-moving. Expect different heap sizes, pause behavior and fragmentation patterns. V8-specific flags such as --max-old-space-size do not carry over to Bun.

Aspect Node.js Bun
Engine V8 JavaScriptCore
Implementation language C++ Zig (with C++ for engine bindings)
Bundled tooling npm, built-in test runner Package manager, test runner, bundler
TypeScript handling tsc, a loader, or built-in type stripping Native transpilation, no type-checking
Release model Scheduled releases with LTS lines Frequent releases, no formal LTS
Maturity Over a decade of production use Younger, evolving quickly
           Node.js project                          Bun project
+-----------------------------------+  +-----------------------------------+
| Separate tools (installed apart)  |  | Built into the bun binary         |
| npm / pnpm / yarn                 |  | package manager (bun install)     |
| tsc or a TS loader                |  | test runner (bun test)            |
| bundler of your choice            |  | bundler (bun build)               |
| node --test or another runner     |  | TS/JSX transpiler (no type-check) |
| tsc for type-checking             |  | tsc still needed for type-checks  |
+-----------------------------------+  +-----------------------------------+
                  |                                      |
                  v                                      v
+-----------------------------------+  +-----------------------------------+
| Node.js runtime (C++)             |  | Bun runtime (Zig + C++ bindings)  |
| Node APIs (fs, http, ...)         |  | Node-compatible APIs + Bun APIs   |
| Event loop: libuv                 |  | Event loop: Bun's own (Zig)       |
+-----------------------------------+  +-----------------------------------+
| Engine: V8                        |  | Engine: JavaScriptCore (JSC)      |
| JIT: Ignition -> Sparkplug ->     |  | JIT: LLInt -> Baseline -> DFG     |
|      Maglev -> TurboFan           |  |      -> FTL                       |
| GC: generational; young-gen       |  | GC: Riptide; concurrent,          |
|      scavenger + old space        |  |      non-moving                   |
| --max-old-space-size applies      |  | V8 heap flags do not apply        |
+-----------------------------------+  +-----------------------------------+

Node.js pairs a C++/libuv runtime on V8 with separately installed tools, while Bun packs its tooling and a Zig runtime on JavaScriptCore into one binary, with different JIT tiers and garbage collectors underneath.

How Bun compatibility with Node.js works and where it breaks

Bun aims to run existing Node.js code unmodified. It does this by reimplementing the node: built-in modules natively, by resolving packages from node_modules with Node's resolution rules, and by honoring package.json fields such as exports and main. Bun's own documentation describes parity as an ongoing goal, not a guarantee. Coverage is broad, but each built-in is a separate implementation written against Node's documented behavior. That behavior is not always fully documented.

Where the gaps usually appear

  • Native addons and N-API modules. Bun supports N-API, but packages that rely on less common N-API functions or link against Node or V8 internals directly can fail to load or behave differently.
  • Less common node: APIs. Widely used modules like fs, http, and crypto get the most attention. Rarely used options, flags, and edge-case semantics are more likely to be partial.
  • V8-specific behavior. Bun runs on JavaScriptCore. Code that depends on V8 stack trace formats, v8 module APIs, heap snapshot tooling, or V8 flags will not carry over.
  • Libraries that patch Node internals. APM agents, tracing SDKs, and async context propagation often hook module loading, wrap internal functions, or depend on precise async_hooks timing. A small difference here can silently drop spans or lose request context.

Why a smoke test isn't enough

Incompatibilities rarely break bun install or startup. They tend to surface at runtime, on specific code paths: an uncommon stream option, an error-handling branch, a context lost across a particular await. A service can boot, pass health checks, and serve the happy path while still diverging from Node in places your smoke test never touched.

How to run a compatibility audit on your dependency graph

Start with a clean checkout and run bun install. Watch for packages whose install scripts did not run. Bun does not execute lifecycle scripts for dependencies by default unless you mark them as trusted, so a missing postinstall step can look like a runtime bug later. Then run bun test. Bun's runner covers the Jest-style API, so failures split into two kinds: test-harness differences (mocking, timers, snapshot formats) and real runtime divergence. Triage the second kind first.

Next, inventory native code. Search node_modules for files ending in .node and for any binding.gyp. Each hit is a package that depends on the N-API surface or on a compiled ABI, and those are the most likely to fail at load time.

Then look at libraries that hook into the runtime instead of just calling it. APM agents usually instrument by patching require or core modules like http. If those hooks don't fire, the agent loads without error and reports no spans. ORMs, database drivers and auth libraries often rely on AsyncLocalStorage for transactions, request context or tracing. A context lost across an await shows up only under concurrent load, so test those paths with parallel requests.

Dependency category Risk How to verify
Native / N-API modules High Find .node files and binding.gyp; load and exercise each
APM / tracing agents High Confirm spans actually arrive in staging
Core-module monkey-patchers High Grep for patches to http, net or require
ORMs, DB drivers Medium Run transactions and pooling under concurrency
Auth / session libraries Medium Test token flows and per-request context isolation
Pure-JS utilities Low Covered by bun test
#!/usr/bin/env bash
set -euo pipefail
mkdir -p audit

pkgs() { sed -E 's#.*node_modules/((@[^/]+/)?[^/]+)/.*#\1#' | sort -u; }

rm -rf node_modules
bun install 2>&1 | tee audit/install.log

grep -lE '"(preinstall|install|postinstall)"[[:space:]]*:' \
  node_modules/*/package.json node_modules/@*/*/package.json 2>/dev/null \
  | pkgs > audit/install-scripts.txt || true

bun test 2>&1 | tee audit/test.log || echo "bun test failed: triage audit/test.log"

find node_modules -type f \( -name '*.node' -o -name 'binding.gyp' \) \
  | pkgs > audit/native.txt

grep -rlE 'AsyncLocalStorage|async_hooks' node_modules \
  --include='*.js' --include='*.cjs' --include='*.mjs' \
  | pkgs > audit/async-context.txt || true

grep -rlE 'Module\._load|Module\.prototype\.require|require-in-the-middle|shimmer' node_modules \
  --include='*.js' --include='*.cjs' --include='*.mjs' \
  | pkgs > audit/monkey-patch.txt || true

wc -l audit/*.txt

A shell script that installs and tests under Bun, then writes per-category package lists (install scripts, native addons, AsyncLocalStorage users and module patchers) for the manual checks in the table.

Bun performance for API workloads: where it matters and how to benchmark it honestly

Bun's advantages are real but narrow. It typically starts processes faster, installs dependencies faster, and ships an HTTP server implemented natively rather than in JavaScript. Those gains matter most where the runtime itself dominates: serverless cold starts, CI pipelines that reinstall and boot repeatedly, and CPU-light handlers that do little beyond routing and serialization. They matter much less once a request waits on Postgres, Redis, or an upstream API, because the runtime is idle during that wait.

Worked example (illustrative estimate). Assume a request spends 45 ms on I/O and 5 ms on runtime CPU work, for 50 ms total. If a runtime ran the CPU portion twice as fast, latency would drop to 47.5 ms, a 5% gain. At five times faster it would be 46 ms, an 8% gain. The I/O share caps the benefit no matter how large the runtime speedup is.

Benchmarking without fooling yourself

  • Hold the environment constant. Use identical hardware, container CPU and memory limits, and the same load generator running on a separate machine.
  • Use realistic handlers. Each request should query a real database through your actual driver and pool, then serialize a representative JSON payload. "Hello world" tests measure only the HTTP layer.
  • Warm up first. Discard the initial minutes so JIT compilation and connection pools stabilize before you record anything.
  • Report p50 and p99 latency alongside throughput. A higher request rate can hide worse tail latency.
  • Measure memory under sustained load, not at idle, and watch for growth over time.

Write the target as a single handler that takes a Web-standard Request and returns a Response. Both runtimes can serve that same code, so any measured difference comes from the runtime and not from changes to the application code.

// handler.ts: the single benchmark target, shared unchanged by both runtimes.
import { Pool } from "pg";

// Identical pool settings in both runtimes, so the database side stays constant.
const pool = new Pool({
  connectionString: process.env.DATABASE_URL, // e.g. postgres://bench:bench@db:5432/bench
  max: 10,
});

export async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);
  if (req.method !== "GET" || url.pathname !== "/orders") {
    return new Response("Not found", { status: 404 });
  }

  const customerId = Number(url.searchParams.get("customer") ?? "1");

  // A real query through the real driver and pool. This is the I/O share of latency.
  const { rows } = await pool.query(
    "SELECT id, status, total_cents, created_at FROM orders WHERE customer_id = $1 ORDER BY created_at DESC LIMIT 50",
    [customerId]
  );

  // Representative JSON serialization. This is the runtime CPU share.
  const body = JSON.stringify({ customerId, count: rows.length, orders: rows });

  return new Response(body, {
    headers: { "content-type": "application/json" },
  });
}

// Entry points stay thin so they don't skew results:
//   Bun:  Bun.serve({ port: 3000, fetch: handler });
//   Node: a small node:http adapter that builds a Request from the incoming
//         message, awaits handler(), and streams the Response back.

A Web-standard Request/Response handler that runs a real Postgres query and serializes a JSON payload, so Bun and Node.js can serve identical application code and any measured difference comes from the runtime.

Tooling consolidation and its impact on cost and engineering time

Bun ships a package manager, test runner, bundler and TypeScript transpiler in one binary. A typical Node.js service assembles the same roles from separate projects:

Tooling role Bun built-in Common Node.js options
Package manager bun install npm, pnpm, Yarn
Test runner bun test node:test, Jest, Vitest, Mocha
Bundler bun build esbuild, webpack, Rollup
TypeScript execution Native transpilation tsc, tsx, ts-node, swc
Watch mode bun --watch node --watch, nodemon

Consolidation means fewer config files, a smaller devDependencies tree and fewer packages to audit. Install speed is usually the most visible gain in CI. The gap is narrower than it used to be, because recent Node.js releases include a built-in test runner and watch mode.

The trade-off is coupling. In Bun, a test-runner fix arrives with a runtime upgrade, and a runtime regression can block that fix. On Node.js you can bump Vitest or swap pnpm for npm without touching the runtime.

Weigh the savings against new costs:

  • Migration effort. You have to rewrite test mocks and replace bundler plugins.
  • Mixed-fleet overhead. You maintain two base images, two debugging runbooks and two upgrade policies.
  • Onboarding. Most engineers you hire will know Node.js tooling, not Bun's.

Illustrative estimate (assumed numbers, not a benchmark). Annual net saving = (CI minutes saved per run × runs per year × cost per minute) + config hours saved − (migration hours + yearly mixed-fleet hours) × hourly rate.

Assume 2 minutes saved per run, 20,000 runs per year and $0.01 per minute. That is $400 of CI savings. One week of migration work at $100 per hour costs $4,000, so it would take a decade to recover through CI alone. In this example, the case depends on engineering time saved, not on compute.

Operational maturity: what to check before running Bun in production

Speed and CI savings matter less than whether your team can diagnose a failure at 3 a.m. Check these before committing.

Debugging and profiling. Bun runs on JavaScriptCore, not V8, so V8 heap snapshots, --prof output, and the flame-graph workflows built around them do not carry over directly. Confirm that your engineers can capture a CPU profile and a heap snapshot from a running Bun process, and open them in tools they already know.

Observability. Many APM and tracing agents instrument Node.js through async_hooks, diagnostics_channel, and module-loading hooks. If Bun implements those partially, the agent can still load but produce incomplete traces. Check your vendor's documented Bun support and verify that spans propagate across await boundaries under real load.

Memory in long-running processes. JavaScriptCore's garbage collector behaves differently from V8's. Run a soak test lasting hours or days, and watch resident memory, not only the JS heap.

Release cadence. Bun ships frequently, and fixes can change runtime behavior. Pin the exact version in your container base image, and treat upgrades like dependency upgrades that need tests. Node.js offers a published LTS schedule, years of security backports for each LTS line, and broad support from security scanners.

People. Most backend engineers arrive knowing Node.js. Bun-specific failure modes have less community documentation, so budget time for onboarding and for diagnosing problems nobody has written up yet.

Who should use Bun in production

Bun suits teams that can own the runtime, not just consume it. In practice that means:

  • Small to mid-sized teams building new TypeScript services who want one tool for installs, tests, bundling and execution, and who have no large legacy dependency graph to audit.
  • Teams with engineers willing to debug below the framework layer. When something diverges from Node.js, someone has to read Bun's issue tracker, reproduce the problem in isolation, and decide whether to work around it or wait for a fix.
  • Platform and CI owners who want faster installs and test runs, even if production stays on Node.js. This group gets value from Bun with almost no production risk.
  • Teams whose observability does not depend on an agent that patches Node internals, or whose vendor documents Bun support and has been verified in staging.

Node.js remains the better default for teams that rely on vendor support contracts, that run many services owned by different groups, or that lack anyone with time to investigate runtime-level issues.

When to use Bun

Use Bun when its specific strengths line up with your workload and your risk tolerance:

  • Startup time is a visible cost. Serverless functions, CLIs, short-lived jobs and autoscaled containers that boot often benefit most from faster process start.
  • The runtime does a meaningful share of the work. CPU-light handlers that mostly route, validate and serialize JSON leave more room for a faster runtime to show up in latency, as the worked example above shows. I/O-bound services gain less.
  • You are starting fresh. A greenfield service can be written against Web-standard Request and Response from day one, with dependencies chosen after checking they run on Bun.
  • Tooling sprawl is the actual pain. If your team spends real time maintaining separate configs for a package manager, test runner, transpiler and bundler, consolidating them can save engineering time, which the cost estimate above suggests is where most of the benefit lies.
  • Development and CI only. Running bun install or bun test in CI while production stays on Node.js is a reasonable choice for almost any team, as long as a Node.js test job still gates releases.

When to avoid Bun

Stay on Node.js, or keep Bun out of the production path, in these cases:

  • Your dependency graph includes native addons that fail to load or behave differently under Bun, and you cannot replace them. The audit script above will show how many you have.
  • You depend on APM, tracing or security agents that instrument Node.js through module patching or async_hooks and do not document Bun support. Missing spans fail silently, so you may not notice until an incident.
  • You need a formal LTS line, long security backport windows, or a runtime explicitly covered by your compliance or vendor support agreements.
  • Your diagnostics rely on V8 tooling. Heap snapshot workflows, --prof, V8 flags and memory tuning via --max-old-space-size do not transfer to JavaScriptCore.
  • The service is large, critical and I/O-bound. Migration risk is high, and the I/O share of each request caps the latency gain, so the trade rarely pays off.
  • Nobody on the team can absorb runtime-level debugging. If a behavior difference would stall the team rather than prompt an investigation, the speed and tooling benefits are not worth the exposure.

Incremental adoption paths and a decision framework

The lowest-risk entry point leaves the production runtime alone. Use Bun as a package manager or test runner in CI and on developer machines while Node.js keeps serving traffic. Installs and test runs may get faster, and rollback means editing a CI script, not redeploying. One caveat: tests that pass under Bun do not prove identical behaviour under Node.js, so keep a Node.js test job for anything that ships.

The next step is a pilot on a new, low-criticality service, such as an internal API or a webhook receiver. An outage there costs little, and there is no legacy dependency graph to audit first.

Portability determines how cheap rollback stays. Bun.serve, bun:sqlite and Bun.file are convenient, but every call is code you must rewrite if you leave. Write handlers against the Web-standard fetch, Request and Response, and confine runtime-specific wiring to a thin entry file. On Bun, that file passes the handler straight to the built-in server. On Node.js, a small adapter converts the incoming request into a Request and writes the returned Response back. The example below shows this shape.

Situation Default choice Why
Large existing codebase Node.js Migration risk outweighs gains
Heavy native dependencies Node.js Native addon support is the most common breakage
Strict LTS or compliance requirements Node.js Established release and support policy
Vendor-supported instrumentation required Node.js APM agents target Node.js first
Greenfield TypeScript service Bun Runs TypeScript directly, no legacy dependencies
Startup-sensitive workloads Bun Faster cold starts can matter
Team values integrated tooling and can absorb ecosystem risk Bun One tool replaces several
// handler.ts: runtime-neutral, uses only Web-standard Request/Response
export async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);
  if (url.pathname === "/health") return new Response("ok");
  if (req.method === "POST" && url.pathname === "/webhook") {
    const event = await req.json();
    return Response.json({ received: event.type ?? null });
  }
  return new Response("not found", { status: 404 });
}

// server.bun.ts: Bun entry passes the handler straight to the built-in server
import { handler } from "./handler";
Bun.serve({ port: 3000, fetch: handler });

// server.node.ts: Node.js entry, a thin adapter around node:http
import { createServer } from "node:http";
import { handler } from "./handler";

createServer(async (req, res) => {
  // Buffer the body. Fine for small webhook payloads, not for large uploads.
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk);

  const headers = new Headers();
  for (const [name, value] of Object.entries(req.headers)) {
    if (Array.isArray(value)) value.forEach((v) => headers.append(name, v));
    else if (value !== undefined) headers.set(name, value);
  }

  const hasBody = req.method !== "GET" && req.method !== "HEAD";
  const request = new Request(` ?? "localhost"}${req.url}`, {
    method: req.method,
    headers,
    body: hasBody ? Buffer.concat(chunks) : undefined,
  });

  const response = await handler(request);
  // Note: this collapses repeated headers such as multiple Set-Cookie values.
  res.writeHead(response.status, Object.fromEntries(response.headers));
  res.end(Buffer.from(await response.arrayBuffer()));
}).listen(3000);

One fetch-style handler written against Request and Response, wired to Bun.serve in one entry file and to Node.js's node:http through a small adapter in another, so leaving Bun means deleting one file.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to bun vs node.js production, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.