What actually differs: engines, APIs and toolchain scope
Node.js embeds Google's V8 engine. Bun embeds Apple's JavaScriptCore. The engine runs the language itself. The runtime adds the parts a server needs: file and network I/O, the module loader, and globals such as Buffer. Node.js exposes its own node: modules. Bun implements many of those for compatibility and adds its own APIs, such as Bun.serve.
The engines warm up differently. They use different JIT tiering, garbage collectors and heap layouts. As a result, startup time, the time to reach steady-state throughput, pause behaviour and resident memory can all move in different directions depending on the workload. A short-lived CLI, an allocation-heavy JSON service and a long-running WebSocket server put load on different parts of the engine. A benchmark of one tells you little about the others.
The two projects also cover different amounts of tooling. Bun ships one binary that bundles the runtime and its tools. Node.js splits the same work across separate pieces:
| Component | Bun | Node.js |
|---|---|---|
| Engine | JavaScriptCore | V8 |
| Package manager | bun install |
npm (bundled), or a third-party manager |
| Package executor | bunx |
npx |
| Test runner | bun test (Jest-compatible) |
node:test |
| Bundler | bun build |
None built in |
| TypeScript execution | Runs TS/TSX directly | Native TypeScript execution, as described in the Node.js docs |
You can adopt each component on its own. For example, you can use bun install or bun test in a project that still runs on Node.js in production.
TypeScript support no longer separates the two runtimes much. Both can now run .ts files without a separate compile step. Neither checks types when it runs code: both remove the type annotations and execute the result. You still need tsc (or an equivalent type checker) in CI.
How Bun works when it runs a Node.js server
Bun is a single native executable, written mostly in Zig, with JavaScriptCore linked in. When you run bun server.js (or bun server.ts), the same process does several jobs that a Node.js setup often hands to separate tools:
- Loading and transpiling. Bun's built-in transpiler reads each source file as it is imported. It removes TypeScript annotations and converts JSX before passing the result to JavaScriptCore. It does not type-check.
- Module resolution. The loader resolves both ES modules and CommonJS. It follows
package.jsonfields such asexportsandmainin the same way the Node.js resolver does, so packages written for Node.js usually load without changes. You canimporta CommonJS module, and a CommonJS file canrequireother modules. - Built-in modules. When code imports a
node:module, Bun provides its own implementation of that module's API. Some are written natively and some in JavaScript on top of Bun's internals. How complete each module is depends on the Bun version, which is why the compatibility checks below matter. - Web-standard globals.
fetch,Request,Response,URLand Web streams are available as globals.Bun.serveis built directly on them. - Environment. Bun loads
.envfiles from the project directory intoprocess.envautomatically. Node.js does not do this unless you ask it to. If your deployment sets environment variables some other way, a stray.envfile can quietly change behaviour.
bun install is part of the same binary. It reads package.json, resolves the dependency graph, stores packages in a global cache, and fills a standard node_modules directory. The project's code therefore sees the usual layout, whether it later runs on Bun or on Node.js. It records the result in Bun's own lockfile, bun.lock.
In practice, Bun runs your existing server through a compatibility layer that mirrors the Node.js APIs, not through Node.js. Most code never notices the difference. The places where it does notice are the subject of the next section.
Compatibility: where migrations break
Bun's documentation describes the runtime as "designed as a drop-in replacement for Node.js" (Bun docs). That describes a design goal. It does not guarantee that your service will behave the same. Speed only matters once the code runs correctly, so the first question is whether your dependency tree works on Bun at all.
What to verify
Check each item against Bun's own compatibility documentation for the version you plan to deploy:
node:built-ins your code and dependencies import, especiallynode:http,node:net,node:tls,node:crypto,node:worker_threads,node:child_processandnode:async_hooks.- Globals such as
Buffer,processandprocess.env. Also check edge behavior like signal handling and exit codes. - Module resolution, including CommonJS/ESM interop,
package.jsonexportsconditions, and any code that depends onrequire.cacheor loader hooks.
Native addons are the highest risk
Packages built with Node-API or node-gyp contain compiled code. A pure-JavaScript shim cannot fix them if the runtime does not support them. These packages are often the ones a service cannot run without: database drivers, crypto and hashing libraries, and image processing libraries. To build an inventory, find every installed package that ships a binding.gyp or a prebuilt .node binary. The shell example below does this by searching node_modules. For each match, record which code paths use it.
Partial compatibility fails late
A missing API rarely stops the server from starting. The failure usually appears on a rarely used code path, such as a retry branch, an admin export or a TLS renegotiation, and it can appear weeks after cutover, in production. You cannot rely on smoke tests to find these gaps. You need test coverage on the paths that matter.
#!/usr/bin/env bash
set -euo pipefail
cd "${1:-.}"
pkg_dir() {
# Turn a file path back into the directory of the package that owns it.
# Handles @scope/name and nested node_modules (the greedy match finds the last one).
sed -E 's#^(.*node_modules/(@[^/]+/)?[^/]+)/.*$#\1#'
}
{
# Packages that compile from source with node-gyp at install time
find node_modules -type f -name binding.gyp
# Packages that ship prebuilt Node-API or other addon binaries
find node_modules -type f -name '*.node'
} | pkg_dir | sort -u > native-packages.txt
echo "Packages with native code: $(wc -l < native-packages.txt)"
while read -r dir; do
name="${dir##*node_modules/}"
echo "== $name"
npm ls "$name" --all 2>/dev/null || true
done < native-packages.txt
A shell script that searches node_modules for binding.gyp files and prebuilt .node binaries, writes the list of packages with native code to native-packages.txt, and shows which dependency pulls in each one.
HTTP servers: Bun.serve vs node:http and the rollback path
A minimal node:http server is the portable baseline. You call createServer with a callback that receives a request and a response object, write headers and a body, and call listen on a port. Node.js runs this natively, and Bun implements node:http as part of its compatibility layer, so the same file starts under either node server.js or bun server.js. The callback style is verbose. You stream the body yourself, set status codes on a mutable response, and parse URLs manually.
Bun.serve is more ergonomic. You pass an options object with a fetch handler that takes a Web-standard Request and returns a Response. Routing, WebSocket upgrades and TLS options live in the same object, and the handler shape is similar to what you would write for other fetch-based platforms. The cost is that Bun.serve exists only on Bun. Node.js has no Bun global, so the process fails at startup.
This matters most during an incident. If a server written against node:http misbehaves under Bun, you roll back by changing the runtime in your container image or process manager and redeploying the same code. If the server is written against Bun.serve, rolling back means rewriting the entry point, rebuilding and testing it while production is degraded.
To keep the exit open, write your server against node:http, or use a framework that ships adapters for both runtimes, so that the choice of runtime remains a deployment setting. Confine Bun-specific APIs to code paths that you can replace in an afternoon, and track every place you use them.
// server-node.js: portable baseline. Runs unchanged with `node server-node.js` or `bun server-node.js`.
import { createServer } from 'node:http';
const port = Number(process.env.PORT ?? 3000);
createServer((req, res) => {
// Parse the path manually; req.url is only the path and query string.
const pathname = (req.url ?? '/').split('?')[0];
if (pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
}).listen(port);
// server-bun.js: Bun-only. Under Node.js, `Bun` is undefined and startup fails
// with a ReferenceError, so rolling back means rewriting this entry point.
Bun.serve({
port: Number(process.env.PORT ?? 3000),
// Web-standard handler: takes a Request and returns a Response.
fetch(req) {
const url = new URL(req.url); // req.url is already an absolute URL here
if (url.pathname === '/health') {
return Response.json({ ok: true });
}
return new Response('not found', { status: 404 });
},
});
The same health-check endpoint written against node:http, which starts under both Node.js and Bun, and against Bun.serve, which starts only under Bun and so turns a runtime rollback into a code change.
Testing performance claims on your own service
Vendor speed figures, including the install and runtime comparisons in the Bun docs, come from workloads and hardware the vendor chose. Nobody measured them on your service. Treat them as hypotheses until your own service reproduces them.
A credible test runs the real service, not a hello-world handler. Feed it a payload mix that matches production: the same read/write ratio, realistic body sizes and real downstream calls. Discard the warm-up period so JIT compilation, caches and connection pools settle before you measure. Give both runtimes the same container CPU and memory limits. If they don't match, you are partly measuring the limits instead of the runtime.
Hello-world and install microbenchmarks overstate the gains because they only measure work the runtime controls. An I/O-bound service spends most of each request waiting on the network or the database, and the runtime can't speed that up.
Illustrative estimate (assumed inputs, not measured): a request takes 50 ms end to end, of which 42 ms is database time and 8 ms is in-process work. Suppose the new runtime cuts in-process work by 25%. That saves 2 ms, or 4% of latency. If the database connection pool sets the throughput ceiling, requests per second may not change at all.
| Dimension | What it reveals |
|---|---|
| Throughput at fixed error rate | Capacity before the service degrades |
| p50 and p99 latency | Typical speed vs. the tail users notice, including GC pauses |
| RSS over a sustained run | Leaks and memory growth that short runs hide |
| Cold start to first request | Effect on autoscaling and deploys |
| CPU per request | Whether savings translate into fewer instances |
Operational maturity: debugging and observability on a bad day
Node.js has years of diagnostics documentation behind it. The official learning material covers the inspector for live debugging, heap snapshots and the heap profiler for memory, GC tracing, and CPU investigation with Linux perf and flame graphs (Node.js Learn). These workflows are widely understood, so an on-call engineer at 3 a.m. can usually find a runbook, a blog post or a colleague who has done it before.
Verify before the incident does
Do not assume that the tools you rely on behave the same under Bun. Before any production cutover, check each of these against your actual service:
- Can you capture a CPU profile and a heap snapshot, and open them in the tools your team already uses?
- Does your APM or tracing agent load, instrument your HTTP and database clients, and report correct spans?
- Does attaching a debugger work in your container and hosting environment?
- Does your platform provider support Bun as a runtime?
Where the risk sits
Bun runs on JavaScriptCore, not V8 (Bun docs). Memory flags, GC tuning and heap-analysis habits built for V8 may not carry over, so plan to relearn them. Node.js publishes long-term-support release lines with predictable maintenance windows. Check what support policy applies to any Bun version you plan to pin.
Bun's single binary also concentrates risk. It provides the runtime, the package manager, the test runner and the bundler. One bad release can break installs, tests, builds and production at the same time. Pin the version, and upgrade it as one deliberate change with its own rollback plan.
A staged migration path: tooling first, production runtime last
A test or install job can fail without affecting customers, so start there. Production should be the last place Bun runs. Each stage below has a gate, and a stage starts only after the one before it has met its gate:
[1] CI: bun test / bun install --(green for N runs)--> [2] Staging under Bun --(full suite + soak)--> [3] Shadow/canary --(error rate, latency, memory match Node)--> [4] Prod on Bun, Node image still deployable
Stage 1 adds a job that does not block merges. It runs bun install --frozen-lockfile and then bun test next to your existing npm ci && npm test job. This is where you save engineering time: installs and test runs get faster, and you see compatibility failures in your own dependency tree before anyone touches a server. If something goes wrong, you delete a CI job.
Stage 2 runs the service itself under Bun in staging against the same test suite. Stage 3 sends mirrored or canary traffic to it, so you compare real request shapes rather than synthetic ones. Stage 4 switches the production runtime. Keep building and tagging the Node.js image so that rollback is a redeploy, not a rebuild.
Choose one package manager to own the lockfile. If Bun and npm each write their own lockfile, they will resolve different versions over time, and test results from one runtime stop applying to the other. Until stage 4 is finished, you are paying for two of everything: two base images to patch, two sets of CI jobs, and runbooks that need a Bun branch. Plan to remove the Node.js copies on a fixed date, or both stacks will stay in place indefinitely.
name: ci
on: [push, pull_request]
jobs:
node:
# Required check. Node.js is still the production runtime.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
bun-canary:
# Informational only. A red run here is a compatibility signal, not a blocked merge.
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: oven-sh/setup-bun@v2
# npm still owns the lockfile. Install from package-lock.json so both jobs
# test the same dependency tree and no second lockfile drifts out of sync.
- run: npm ci
# Run the existing tests with Bun's test runner.
- run: bun test
# When Bun takes ownership of the lockfile, replace `npm ci` above with:
# bun install --frozen-lockfile
# Commit bun.lock, delete package-lock.json, and switch the node job at the same time.
A GitHub Actions workflow for stage 1: a non-blocking Bun test job runs next to the required Node.js job, and both install from the same npm-owned lockfile so their results stay comparable.
Who should use Bun
Bun suits teams that can check its compatibility claims against their own code and that gain from what it bundles together:
- Teams starting new services with few or no native addons. There is no existing behaviour to preserve, and you can write against
node:httpor a dual-runtime framework from the first commit. - Small teams tired of toolchain sprawl. One binary that installs packages, runs tests, bundles code and executes TypeScript replaces several dev dependencies and their configuration.
- Teams whose CI time goes mostly to installs and test runs. They can use Bun's tooling alone (stage 1 above) and get the benefit without changing what runs in production.
- Short-lived processes such as CLIs, scripts and build steps, where startup time makes up a large share of total runtime. Measure this on your own workload rather than assuming it.
- Teams willing to pin and validate. Bun suits engineers who will lock a version, run their full test suite on every upgrade, and keep a Node.js rollback path.
When Bun should be avoided
Stay on Node.js for the production runtime, or limit Bun to tooling, when any of these apply:
- Your service depends on native addons built with Node-API or
node-gyp, such as database drivers, crypto or image libraries, and you haven't confirmed each one works under the Bun version you would deploy. - You can't test the rarely used code paths. Partial compatibility fails late, so without coverage of retry, error and admin paths you won't find gaps until production does.
- You rely on specific observability tooling. If your APM agent, profiler or heap-analysis workflow assumes V8 and Node.js, and you haven't verified it under Bun, an incident is the wrong time to find out.
- Your organisation requires a published LTS policy for production runtimes, and the Bun version you would use doesn't meet it.
- You run a large fleet on shared base images. Adding a second runtime multiplies patching, CVE tracking and runbook work across every service.
- Your hosting platform doesn't support Bun as a first-class runtime, so you would have to maintain custom images.
- The service is I/O-bound and already fast enough. If the database or network dominates latency, the runtime switch costs effort and adds risk for little measurable gain.
When to choose Bun, when to stay on Node.js, and the cost impact
That fixed date forces the real question: which of the three options are you actually choosing? Those options are staying on Node.js, adopting only Bun's tooling (install, test, bundling) while production keeps running Node.js, or switching the production runtime.
| Criterion | Recommendation | Why |
|---|---|---|
| Greenfield service, few native dependencies | Full runtime switch | There is no legacy behaviour to preserve, and little surface for compatibility gaps |
| Small team frustrated by toolchain sprawl | Bun tooling, then evaluate the runtime | One binary replaces several dev tools without touching production |
| Large fleet with shared base images | Stay on Node.js or tooling only | A second runtime multiplies patching and incident surface across every service |
| Heavy native-addon use | Stay on Node.js | Addons built against Node-API or with node-gyp are where compatibility most often breaks |
| Strict observability or APM requirements | Stay on Node.js | Profilers, heap tooling and agents target Node.js first |
| Organisational LTS policy | Stay on Node.js | Node.js publishes a defined LTS schedule that compliance processes can reference |
Weigh costs the same way. Migration effort, dual-runtime upkeep and retraining are paid in engineer hours. The main return is CI time engineers no longer spend waiting.
Illustrative estimate (assumed numbers, not a benchmark).
- Monthly savings (h) = minutes saved per CI run × runs per month ÷ 60 × fraction of runs someone waits on
- Break-even (months) = one-off migration hours ÷ (monthly savings − monthly dual-runtime overhead)
Assume 4 minutes saved per run, 1,500 runs per month and 25% of runs waited on. That gives 4 × 1,500 ÷ 60 × 0.25 = 25 h/month. With 120 hours of migration work and 10 h/month of overhead, break-even = 120 ÷ (25 − 10) = 8 months.
If overhead equals or exceeds savings, the migration never pays back. The tooling-only option usually breaks even sooner because production overhead stays near zero.
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 nodejs, 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.