What actually differs between Bun and Node.js under the hood

Both are JavaScript runtimes: an engine that parses, compiles and runs JS, plus a native layer for I/O, timers, networking and the module system. Most differences come from those two choices.

        Node.js                          Bun
+------------------------+   +------------------------+
| Node APIs (JS + C++)   |   | Node-compatible + Bun  |
|                        |   | APIs (Zig + C++)       |
+------------------------+   +------------------------+
| V8 engine              |   | JavaScriptCore engine  |
+------------------------+   +------------------------+
| libuv event loop,      |   | Bun's own event loop   |
| thread pool            |   | and I/O layer (Zig)    |
+------------------------+   +------------------------+
|           Operating system (epoll / kqueue)         |
+-----------------------------------------------------+
Aspect Node.js Bun
Engine V8 (Chromium) JavaScriptCore (WebKit)
Native layer C++ on libuv Mostly Zig, with C++ bindings
Tooling Runtime; npm ships alongside Runtime, package manager, test runner, bundler in one binary
TypeScript Needs a loader or build step for most setups Runs .ts directly

The engine shapes behaviour in four ways. Startup depends on how fast the engine reaches executable code and how much runtime bootstrap runs first. Bun's lean bootstrap is the main reason it often starts faster. JIT warm-up differs because each engine has its own interpreter and optimizing tiers and its own rules for when hot functions get promoted. That changes how quickly throughput settles. Garbage collection strategies differ, which affects pause patterns and heap growth under load. Memory profile follows from all three plus the native layer's own allocations.

None of these produce a fixed winner. Results depend on the workload, so they have to be measured per service.

Teams usually look at Bun for two reasons. One is consolidation: a single binary can replace a package manager, test framework, bundler and TypeScript loader. The other is faster cold starts, which matter for CLIs, serverless functions and fast-scaling containers.

                    your-app (.js/.ts)
                             |
              +--------------+--------------+
              v                             v
+---------------------------+ +---------------------------+
| Node.js                   | | Bun (one binary)          | <- tooling
| node + npm (separate)     | | run/install/test/build    |
+---------------------------+ +---------------------------+
| Node APIs (JS + C++)      | | Node-compat + Bun APIs    | <- startup cost
|                           | | (Zig + C++)               |
+---------------------------+ +---------------------------+
| V8                        | | JavaScriptCore            |
|  Ignition -> Sparkplug -> | |  LLInt -> Baseline ->     | <- JIT warm-up
|  Maglev -> TurboFan       | |  DFG -> FTL               |
|  GC: Orinoco              | |  GC: Riptide              | <- GC pauses/heap
+---------------------------+ +---------------------------+
| libuv loop + thread pool  | | Bun loop + I/O (Zig)      | <- native memory
+---------------------------+ +---------------------------+
              |                             |
              v                             v
+---------------------------------------------------------+
|            Operating system (epoll / kqueue)            |
+---------------------------------------------------------+

Node.js and Bun stacks side by side, showing the engine's JIT tiers and garbage collector and the native I/O layer, with arrows marking the layers that most affect startup cost, JIT warm-up, GC behaviour and memory use.

Startup time vs steady-state throughput: which one your API needs

Those are real wins, but they describe the first few hundred milliseconds of a process. A long-running API spends almost all of its life in steady state, and that is a different measurement.

Workload What dominates Does faster startup help?
Serverless functions Cold start per invocation Yes, directly
CLI tools, cron jobs Process launch and module loading Yes
Autoscaling bursts Time until a new instance is ready Partly; image pull and health checks add time
Test suites Repeated process and module startup Yes
Long-running HTTP API Database, network, serialization, GC pauses Rarely

For a persistent service, the runtime's share of request latency is often small. The following is an illustrative estimate built entirely from assumed inputs. None of these numbers are measured, and none come from a benchmark.

Illustrative estimate (all inputs assumed, not measured)

  • Assumed request breakdown, for illustration only: database time 18 ms, network and serialization 5 ms, handler CPU 2 ms, giving an assumed total of 25 ms.
  • Assumed speedup, for illustration only: suppose the alternative runtime ran handler code 30% faster. This is a hypothetical input, not a measured property of either runtime.
  • Illustrative calculation: latency saved = handler time × assumed speedup = 2 × 0.30 = 0.6 ms. As a share of the assumed request time, 0.6 / 25 = 2.4%.

Replace these assumed inputs with numbers from your own traces. If handler CPU is a small slice of the total, a faster runtime barely moves p99.

Short runs can also give you the wrong ranking. Bun and Node.js use different JavaScript engines, each with its own JIT tiers and garbage collector. A 30-second test mostly measures warm-up. It finishes before the optimizing compiler settles, before the heap grows to its working size, and before full GC cycles start causing latency spikes. A runtime that wins the first minute can lose at hour three because of memory growth or tail latency. Run load tests long enough to observe those effects.

Why published HTTP benchmarks rarely transfer to real APIs

A "hello world" benchmark returns a constant string or a small JSON object. It exercises socket handling, HTTP parsing, routing and response serialization, and little else. Those are the paths where runtimes differ most, so the gap looks large. A real API request spends most of its time elsewhere: waiting on the network, running library code that behaves similarly on any modern JavaScript engine, and doing I/O the runtime cannot speed up.

Illustrative estimate (assumed inputs, not a measurement)

Assume a 40 ms request, and assume switching runtimes saves 2 ms, all of it in the HTTP and serialization layer:

Request stage Assumed time, current runtime Assumed time, alternative runtime Affected by runtime choice?
Database round-trip 22 ms 22 ms No, bound by network and query plan
ORM query building and hydration 6 ms 6 ms Marginally
Auth middleware (token verify, session lookup) 5 ms 5 ms Marginally
Structured logging 2 ms 2 ms Marginally
HTTP parsing, routing, serialization 5 ms 3 ms Yes
Total 40 ms 38 ms

The saving is 5% of median latency. At p99, where a slow query might take 200 ms, it falls to about 1%. Users will not notice either change.

Against that 2 ms, weigh the cost of a migration: subtle npm compatibility gaps, new behaviour under memory pressure, and unfamiliar tooling during incidents. For a request profile like this one, adding an index or caching the session lookup is cheaper and lower-risk than changing runtimes.

npm compatibility in practice and how to audit your dependency tree

Bun installs from the npm registry and implements most of the Node.js API surface, but its coverage is broad rather than total. The gaps cluster in predictable places:

  • Native addons. Packages compiled with node-gyp or N-API load a compiled binary instead of running as JavaScript. N-API is designed to be ABI-stable, which helps, but addons that call into V8 or libuv directly have no equivalent in Bun's JavaScriptCore engine.
  • V8 internals. Heap snapshot tools, v8 module serializers, and anything that reads stack-trace formats or engine flags assume V8 is present.
  • Less common node: built-ins and edge cases. http, fs, and crypto work for typical calls. Behavior can still differ in rarer options, error codes, and event ordering.
  • APM and instrumentation agents. These rely on async_hooks, module-loading hooks, and preload flags to patch libraries. If they patch the wrong module or fail silently, you lose traces without seeing any error.
  • Tooling that assumes node. Examples include #!/usr/bin/env node shebangs, scripts that spawn node by name, and checks on process.execPath or process.release.

To audit, run npm ls --all and record every transitive dependency. Then search node_modules for binding.gyp files, .node binaries, and package.json files that declare "gypfile": true or preinstall, install, or postinstall scripts. Bun does not run dependency lifecycle scripts unless you allow them, so any package in this list needs explicit review.

A passing smoke test is not proof of compatibility. Mismatches often show up only in rarely exercised paths, such as an error handler, a stream backpressure case, or a retry branch. Your test suite and production-shaped traffic need to reach those paths before you can trust the result.

#!/usr/bin/env bash
set -euo pipefail
OUT=compat-audit
mkdir -p "$OUT"

npm ls --all --parseable > "$OUT/tree.txt" || true

find node_modules -name binding.gyp -type f > "$OUT/binding-gyp.txt"
find node_modules -name '*.node' -type f > "$OUT/native-binaries.txt"

find node_modules -name package.json -type f -print0 |
  while IFS= read -r -d '' f; do
    jq -r --arg f "$f" 'select(type == "object")
      | select(.gypfile == true or .scripts.preinstall
               or .scripts.install or .scripts.postinstall)
      | "\(.name)@\(.version)\t\($f)"' "$f" 2>/dev/null || true
  done | sort -u > "$OUT/install-scripts.txt"

grep -rlE "require\(['\"](node:)?(v8|async_hooks)['\"]\)|from ['\"](node:)?(v8|async_hooks)['\"]" \
  --include='*.js' --include='*.cjs' --include='*.mjs' node_modules \
  > "$OUT/engine-specific.txt" || true

grep -lE '^#!.*[[:space:]/]node' node_modules/.bin/* > "$OUT/node-shebangs.txt" 2>/dev/null || true
grep -rnE "process\.(execPath|release)|spawn(Sync)?\(['\"]node['\"]" src scripts \
  > "$OUT/node-assumptions.txt" 2>/dev/null || true

wc -l "$OUT"/*.txt

A shell audit that records the full dependency tree and lists packages with native addons, install scripts, V8 or async_hooks usage, and code that assumes a node binary, producing a review list rather than a pass/fail result.

How to evaluate Bun against Node.js for your own service

Start with the test suite, because it is the cheapest way to find incompatibilities. Run it under Node with npm test to get a baseline, then under Bun. If the suite uses Jest or Vitest, bun --bun run test forces scripts that would normally launch Node to run on Bun instead. Switching to bun test swaps in a different test runner, so a failure there may come from the runner rather than the runtime. Treat every new failure as a compatibility finding until you can show it is a test artifact.

Next, start the service under each runtime: node server.js and bun server.js. Run both on identical container limits, such as docker run --cpus=2 --memory=1g. Without pinned limits, one runtime may pick up spare host cores or memory, and the comparison measures the host rather than the runtime. Use the same base OS, environment variables and downstream dependencies for both.

Drive both instances with production-shaped traffic. That means replayed or sampled real requests with realistic payload sizes, auth, database calls and the same mix of slow and fast endpoints. Do not use a single hello-world route. Record:

  • p50 and p99 latency: p99 exposes garbage-collection pauses and contention that averages hide.
  • Throughput at a fixed error budget: peak throughput means little if errors are rising at that load.
  • RSS memory over hours, not minutes: leaks and fragmentation only appear over long runs.
  • Error rates by type: these catch the rarely exercised paths that tests missed.

Skip microbenchmarks. They measure the runtime in isolation, not your service.

#!/usr/bin/env bash
set -euo pipefail

npm test
bun --bun run test   # same runner (Jest/Vitest), Bun runtime

LIMITS=(--cpus=2 --memory=1g --memory-swap=1g)
COMMON=(--rm -d -v "$PWD":/app -w /app --env-file ./bench.env)

docker run "${COMMON[@]}" "${LIMITS[@]}" --name svc-node -p 3001:3000 \
  node:22-slim node server.js

docker run "${COMMON[@]}" "${LIMITS[@]}" --name svc-bun -p 3002:3000 \
  oven/bun:1 bun server.js

while true; do
  docker stats --no-stream \
    --format '{{.Name}},{{.MemUsage}},{{.CPUPerc}}' svc-node svc-bun \
    | sed "s/^/$(date -u +%FT%TZ),/" >> mem.csv
  sleep 60
done

Runs the existing test suite under Node and then Bun, starts each runtime in a container with identical CPU and memory limits, and logs memory usage every minute so long-running differences show up.

Incremental adoption paths and the alternatives to a full migration

A full runtime switch is not the only option, and often not the best first step. There are three lower-risk paths.

Use Bun around Node.js, not instead of it. You can run bun install for faster dependency installs in CI, or bun test for its largely Jest-compatible test runner, while production stays on Node.js. The risk is contained because neither tool ships to production. One catch remains: your tests now run on a different runtime than your service. A test that passes under Bun proves less about Node.js behavior, so keep at least one CI job that runs the suite on Node.js.

Migrate one isolated service first. Pick a service with few native addons, a small dependency tree, and a cheap rollback, such as an internal webhook receiver. That gives you real operational data while limiting the damage if something fails.

Stay on Node.js. If your measurements from the previous section show no meaningful gain, staying put is a sound engineering decision.

Whichever path you choose, write handlers against standard Web APIs: fetch, Request, Response and Web Streams. Both runtimes implement them, so a handler that takes a Request and returns a Response moves between runtimes unchanged. Only a thin server adapter differs. The example below shows this pattern.

Bun-specific APIs work the other way. Bun.serve, bun:sqlite and the built-in bundler each tie code to one runtime. If you need to roll back, you have to rewrite those parts at the moment you are already dealing with a production problem. Keep such calls behind a small interface so rolling back means replacing one module.

// handler.ts: portable. Uses only Web APIs and imports nothing runtime-specific.
export async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);
  if (req.method === "POST" && url.pathname === "/webhook") {
    const event = await req.json();
    return new Response(JSON.stringify({ received: event.type ?? "unknown" }), {
      status: 202,
      headers: { "content-type": "application/json" },
    });
  }
  return new Response("Not found", { status: 404 });
}

// server.bun.ts: Bun adapter. Bun.serve accepts the handler as-is.
import { handler } from "./handler";
Bun.serve({ port: 3000, fetch: handler });

// server.node.ts: Node.js adapter (Node 18+). Converts node:http objects to Web types and back.
import { createServer } from "node:http";
import { Readable } from "node:stream";
import { handler } from "./handler";

createServer(async (req, res) => {
  const hasBody = req.method !== "GET" && req.method !== "HEAD";
  const request = new Request(` ?? "localhost"}${req.url}`, {
    method: req.method,
    headers: req.headers as Record<string, string>,
    body: hasBody ? (Readable.toWeb(req) as ReadableStream) : undefined,
    duplex: "half", // required by Node's fetch when the body is a stream
  } as RequestInit);
  const response = await handler(request);
  res.writeHead(response.status, Object.fromEntries(response.headers));
  if (response.body) Readable.fromWeb(response.body as any).pipe(res);
  else res.end();
}).listen(3000);

One Request-to-Response handler served unchanged by a one-line Bun.serve adapter and a small node:http adapter, so a rollback replaces only the adapter file; the Node adapter is deliberately minimal, with no error handling and no support for repeated headers such as multiple Set-Cookie values.

Operational readiness, cost and the decision framework

That rollback path is also the first item on the operational checklist: before production, confirm you can run the same service on Node.js again within one deploy.

Concern What to verify for Bun Node.js baseline
APM and observability agents Your vendor's agent supports Bun, not just "JavaScript" Broad, mature agent support
Profiling and heap snapshots Which profiler and heap-snapshot formats work, and which tools can open them Inspector protocol and well-known tooling
Debugger integration Breakpoints work in your editor and in a running container Standard inspector workflow
Container base images Official or internally maintained images that your scanner accepts Official images in many variants
Security patch cadence How quickly fixes ship and whether they are backported Security releases for LTS lines
Release support policy Whether a supported long-term line exists Published LTS schedule
Runtime-level debugging Someone on the team can read Zig/C++ crash output and file a useful issue Large body of prior issues and answers

Bun's faster installs and test runs save engineering time on every CI run and local loop. Running two runtimes costs time in other ways. Every incident adds the question "which runtime?", runbooks need two branches, and on-call engineers need to know both. Node.js also has deeper hiring pools, documentation and searchable failure reports. When something breaks at 3 a.m., that history is what shortens the outage.

Use Bun for greenfield services, cold-start-sensitive workloads such as serverless functions and CLIs, services with a small dependency surface, and teams willing to track a younger runtime closely.

Avoid Bun for large legacy codebases, services with heavy native-addon dependencies, environments with strict compliance or vendor-support requirements, and teams that depend on mature APM and profiling coverage.

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.