What Bun and Node.js are, and how Bun approaches Node compatibility
Node.js and Bun both run server-side JavaScript and TypeScript, but they are built on different foundations. Node.js embeds Google's V8 engine and uses libuv for its event loop and I/O. Bun embeds JavaScriptCore, the engine from WebKit, and is written largely in Zig. For a team choosing a Node API runtime, the engine matters less than how each runtime implements the APIs your code depends on.
| Aspect | Node.js | Bun |
|---|---|---|
| JavaScript engine | V8 | JavaScriptCore |
node: built-in modules |
Reference implementation | Reimplemented by Bun |
| Package manager, test runner, bundler | Separate tools (npm and others) | Built into the single binary |
| Compatibility target | Defines the behavior | Aims to match Node.js behavior |
Bun does not run Node's code for modules like node:fs, node:http, or node:crypto. It reimplements them, along with Node-style globals and module resolution. This makes Bun compatibility ongoing implementation work, not a finished property. Some modules are complete, some are partial, and some behave differently in edge cases such as error messages, event ordering, or rarely used options. Coverage changes between releases, so check the current state of any API you need against the release you plan to deploy. Do not assume Bun is a drop-in replacement for Node.js.
Teams evaluate Bun for three reasons. It claims faster performance on common server workloads. It starts processes quickly, which matters for CLIs, serverless functions, and test suites. It also ships a package manager, test runner, and bundler in one binary, so a project needs fewer separate tools.
Why microbenchmarks overstate bun performance for real APIs
A hello-world or JSON-echo benchmark measures how fast a runtime can accept a connection, parse HTTP, run a trivial handler, and write a response. That is the HTTP layer and the event loop with nothing else in the way. Bun is fast on exactly that path. A production handler spends most of its time somewhere else: waiting on the network.
Here is the time budget for one typical authenticated read endpoint (illustrative estimate, assumed inputs):
Request: 40 ms total
|------------------------ DB queries (2 round trips) -- 24 ms ------------------------|
|---------- upstream HTTP call -- 10 ms ----------|
|-- serialization/validation -- 4 ms --|
|- runtime + framework -- 2 ms -|
Illustrative estimate. Assume the runtime and framework overhead (HTTP parsing, routing, middleware dispatch) is 2 ms of the 40 ms. If Bun runs that portion twice as fast, it saves about 1 ms, or roughly 2.5% end to end. The 34 ms spent waiting on Postgres and an upstream service stays the same, because the database and remote APIs don't run any faster when you change runtimes. Serialization might improve a little, but it is a small slice too.
The reverse case also holds. CPU-bound handlers can see larger gains, for example template rendering, heavy JSON transformation, hashing, or image work. So can startup-sensitive deployments such as serverless cold starts. Some workloads go the other way. JavaScriptCore and V8 optimize code differently, so a specific hot path can run slower on Bun or hold more memory under sustained load. Throughput numbers from someone else's benchmark won't tell you which case you are in. Only measuring your own service will.
Illustrative estimate (assumed inputs): one 40 ms authenticated read
+--------+ HTTP +------------------------------------+
| Client |---------->| Bun or Node.js process |
+--------+ | |
| +------------------------------+ |
| | HTTP parse, routing, | |
| | middleware ~2 ms | |<- runtime
| +------------------------------+ | speedup
| | handler: serialize/validate | | mostly
| | ~4 ms | | acts here
| +------------------------------+ |
+-------+-------------------+--------+
| |
2 round | | 1 call
trips v v
+--------------+ +---------------+
| Postgres | | Upstream API |
| ~24 ms | | ~10 ms |
+--------------+ +---------------+
network wait: ~34 ms, same on either runtime
2x faster runtime layer: 2 ms -> 1 ms, saves ~1 ms of 40 ms (~2.5%)
Illustrative estimate with assumed inputs: in a 40 ms request, a faster runtime mostly shrinks the ~2 ms HTTP and framework layer, while ~34 ms of database and upstream wait does not change.
Compatibility gaps to audit in your dependency tree
Before any of that measurement is worth doing, the service has to behave correctly on Bun. Bun runs on JavaScriptCore, not V8, and reimplements Node's built-ins itself. Anything that depends on V8 or on how Node's internals happen to behave is a candidate for breakage.
The hardest gaps to catch are behavioural. Timer ordering relative to I/O, the exact text of error messages, and when a writable stream signals backpressure can all differ slightly. Unit tests rarely exercise these. They show up under production concurrency, on retry paths, or when code parses err.message instead of checking err.code.
| Category | Why it's risky | How to check |
|---|---|---|
| Native addons | Addons built on Node-API have a chance to load. Addons that call V8's C++ API directly cannot. | List every .node binary in node_modules and check which binding layer each uses. |
| V8-specific APIs and flags | The v8 module, heap snapshot tooling and flags such as --max-old-space-size assume a V8 engine. |
Grep code and start scripts for v8. and V8 flags. |
| Less common built-in features | worker_threads options, vm contexts, child_process stdio and signal handling, and less common crypto algorithms are where implementations most often diverge. |
Write integration tests for the exact calls you make, including error cases. |
| APM, profilers, instrumentation | These hook into async_hooks, module loading and other internals. They can fail silently and simply drop traces. |
Confirm that spans, metrics and context propagation arrive end to end. |
| Timing, errors, backpressure | Small semantic differences that only appear under load. | Run a load test with slow consumers, and fail on any assertion against error message text. |
How to run a compatibility audit and benchmark your own service fairly
Audit in four passes
- Run the full existing test suite under Bun. Use the same test files and the same runner your CI already uses, so a failure points to the runtime and not to a rewritten test. Treat skipped and flaky tests as findings too, because they often hide timing differences.
- Run integration tests against real databases and queues. Mocks bypass the drivers, connection pools, and TLS handshakes where incompatibilities tend to appear. Point the tests at disposable real instances.
- Inventory native dependencies. Search
node_modulesfor compiled.nodefiles andbinding.gyp. Then confirm that each one loads and that its code paths actually run. A module that imports cleanly can still fail on its first real call. - Verify observability. Confirm that traces reach your backend with correct parent spans, that metrics export, and that logs keep their structure. Also check that you can capture heap snapshots and CPU profiles. Losing profiling ability is a real production cost, even when every test passes.
Benchmark the real service
Start the same entrypoint with node and bun, and keep the framework, environment variables, and instance size identical. Benchmark the actual application with representative payloads, including large and malformed ones. A hello-world handler measures the runtime's HTTP layer and ignores your serialization, validation, and I/O.
Drive the service at the concurrency you see in production, not at saturation. Discard a warm-up period before recording, because JIT compilation and connection pool filling distort the first results.
Record p50 and p99 latency, resident memory, and CPU per instance alongside requests per second. A runtime that raises throughput but also raises p99 or memory can cost more to operate.
#!/usr/bin/env bash
# Compatibility audit plus a same-entrypoint benchmark under node and bun.
set -euo pipefail
ENTRY=src/server.js # placeholder: your real entrypoint
PORT=3000
# Pass 1: same suite, same runner. --bun forces bun as the runtime
# for package.json scripts that would otherwise start node.
npm test > test-node.log 2>&1 || echo 'node: failures, see test-node.log'
bun --bun run test > test-bun.log 2>&1 || echo 'bun: failures, see test-bun.log'
# Pass 3: list native addons. Each one must load AND be exercised later.
find node_modules -name '*.node' -type f | sort > native-binaries.txt
find node_modules -name 'binding.gyp' -type f | sort > native-sources.txt
wc -l native-binaries.txt native-sources.txt
# Benchmark: identical entrypoint, env, and instance size for each runtime.
for RT in node bun; do
PORT=$PORT NODE_ENV=production $RT "$ENTRY" &
PID=$!
sleep 5 # placeholder: replace with a health-check loop
# Warm-up run: JIT and connection pools settle; discard these results.
./load.sh --warmup > /dev/null # placeholder wrapper around your load tool
# Measured run at production concurrency with representative payloads.
./load.sh > "bench-$RT.txt" # must report RPS, p50, and p99
# Per-instance resource cost captured right after load (RSS in KiB).
ps -o rss=,pcpu= -p "$PID" > "resources-$RT.txt"
kill "$PID"; wait "$PID" 2>/dev/null || true
done
A shell script that runs the existing test suite under both runtimes, lists native addons in node_modules, and benchmarks the same entrypoint under node and bun, discarding a warm-up run and recording latency percentiles plus memory and CPU per instance.
Operational differences in production
…erase the gain once you are paying for larger instances or missing latency targets. That trade-off shows up most clearly during long-running operation.
| Concern | Node.js (V8) | Bun (JavaScriptCore) |
|---|---|---|
| Debugging | Chrome DevTools via --inspect, mature IDE support |
Inspector support exists but uses WebKit's protocol, so tooling coverage is narrower |
| Profiling and heap analysis | CPU profiles, heap snapshots, widely understood formats | Available, but fewer third-party analyzers and guides |
| GC tuning | Well-documented V8 flags such as --max-old-space-size |
V8 flags do not apply, and the tuning options are different and less documented |
| APM and tracing agents | Broad vendor and OpenTelemetry support | Varies by agent, so verify before you commit |
| Release support | Even-numbered majors enter long-term support | No comparable LTS practice, so you upgrade more often |
JavaScriptCore and V8 allocate and collect memory differently. A heap ceiling or container memory limit tuned for Node will not carry over. Run a soak test for hours, not minutes, and watch resident memory for slow growth. Short benchmarks rarely show fragmentation or delayed collection.
Containers are simpler in one way, because Bun ships as a single binary. You still need to confirm that the libc variant and any native addons match your base image.
Incident response is where the younger runtime costs the most. With Node, most production failures have been seen and written up before, and on-call engineers know where to look. With Bun, plan for the possibility that you are the first to hit a bug. Keep a tested path to redeploy the same service on Node.
If your tracing or APM agent depends on module-loading hooks or on async_hooks behavior that Bun does not fully implement, treat that as a blocker. A faster service that you cannot observe is harder to operate safely than a slower one you can.
Impact on development cost and engineering time
Bun's clearest savings often come from outside the request path. Startup time matters wherever processes are short-lived: serverless cold starts, scheduled jobs, migration scripts, and CI pipelines that launch a runtime many times per run. The integrated toolchain adds to this. Package installation, a test runner, a bundler, and direct TypeScript execution in one binary remove a build step and several dev dependencies from each repository. You can get much of this benefit while production stays on Node.js, for example by using Bun only for installs or local test runs. Those tests then exercise Bun rather than the runtime you ship, so keep a Node.js test job as the gate for production.
The costs recur rather than ending after migration:
- Upgrade re-validation. Bun's Node compatibility changes between releases, so every runtime upgrade needs the audit from the previous section repeated.
- Dual-runtime overhead. You maintain two CI paths, triage failures that occur on only one runtime, and document which runtime each script expects.
- Lock-in. Each call to a Bun-specific API, such as
Bun.serveorbun:sqlite, turns a later rollback into a rewrite. During a pilot, avoid these APIs or put them behind an interface.
For I/O-bound services, these costs can exceed the runtime gain:
Illustrative estimate (assumed numbers, not a benchmark) Monthly net = compute savings − ongoing maintenance cost Assume $2,000/month in compute. Assume a 10% saving, which is small because most request time is spent waiting on the database. That gives $200. Assume 4 hours/month of re-validation at $100/hour, which costs $400. Net = $200 − $400 = −$200/month, before counting any one-time migration effort.
Bun vs Node.js production decision: who should pilot, when to avoid it, and a low-risk adoption path
That negative result is common, and it frames the decision: a runtime switch pays off only when the runtime itself is a measurable share of your cost or latency.
| Situation | Recommendation | Why |
|---|---|---|
| Heavy native addon use | Stay on Node.js | Addons built against Node's ABI are the most likely to break or behave subtly differently |
| Strict observability or APM requirements | Stay on Node.js | Agents often rely on Node internals and diagnostics hooks |
| Regulated or audited environments | Stay on Node.js | Changing the runtime means re-certifying and re-documenting the whole stack |
| I/O-bound services | Stay on Node.js | Runtime overhead is negligible next to database and network waits |
| CPU-light, startup-sensitive or greenfield services with few native deps | Pilot Bun | Faster startup and less legacy surface to audit |
For a pilot, adopt Bun in stages so each step limits the damage if something goes wrong:
- Tooling first. Use Bun for installs, scripts or tests while production still runs on Node.js. Failures here slow developers down but never reach users.
- A non-critical internal service. This exposes real runtime behaviour under modest load.
- A production canary. Route a small share of traffic to Bun instances behind the load balancer, compare error rates and latency with the Node.js pool, and make sure you can roll back by shifting weight to Node.js.
Throughout the transition, keep the code runnable on both runtimes and avoid Bun-only APIs. That keeps rollback a routing change and avoids a rewrite.
The alternatives are often cheaper. You can stay on Node.js and fix the actual bottlenecks, such as slow queries, missing caches and expensive serialization. You can also use Bun only as a development tool.
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
.envby 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.