What Bun is and how the runtime works

Bun is a single executable, bun, that combines several tools a Node.js project usually installs separately. According to the Bun docs, it includes:

  • A runtime for executing JavaScript and TypeScript, which the project describes as a drop-in replacement for Node.js
  • A package manager (bun install) that works with package.json
  • A test runner (bun test) with a Jest-compatible API
  • A bundler (bun build) for browser and server targets
  • A script runner (bun run) for package.json scripts and source files
  • A package executor (bunx), similar to npx

How the runtime differs

Node.js uses Google's V8 engine. Bun uses JavaScriptCore, the engine Apple develops for Safari, and adds its own implementations of Node.js APIs and Web APIs on top. Bun also transpiles .ts and .tsx files internally when it loads them, so you can run a TypeScript entry point directly without tsc or a separate loader.

This has two practical effects. First, a backend service can drop the compile-then-run step in development and often in production. That means fewer build configs to maintain and fewer source-map problems between what you wrote and what runs. Note that Bun strips types rather than checking them, so you still need a type-check step in CI.

Second, you are running a different engine. JIT behavior, garbage collection and memory profiles will differ from V8. V8-specific tooling and flags may not carry over, so check how you will profile and debug a Bun process before you depend on it.

The Bun project says its runtime has lower startup time and memory use and that its package manager installs faster than npm. Treat these as the project's own claims and measure them on your workload.

+------------------------- bun (single executable) ------------------------+
|                                                                          |
|  bun install   bun test     bun build    bun run      bunx               |
|  (packages)    (Jest API)   (bundler)    (scripts)    (like npx)         |
|                                                                          |
|  +----------------------------- Runtime ------------------------------+  |
|  |                                                                    |  |
|  | app.ts / app.tsx --> built-in transpiler --> JavaScript            |  |
|  |                      (types stripped,            |                 |  |
|  |                       not checked)               v                 |  |
|  | +----------------------------------------------------------------+ |  |
|  | | Node.js APIs + Web APIs (Bun's own implementations)            | |  |
|  | | JavaScriptCore engine (not V8): own JIT, GC, memory profile    | |  |
|  | +----------------------------------------------------------------+ |  |
|  |                                                                    |  |
|  +--------------------------------------------------------------------+  |
|                                                                          |
+--------------------------------------------------------------------------+

  Type checking stays a separate CI step (e.g. tsc --noEmit).

One bun executable holds the package manager, test runner, bundler, script runner, package executor and a runtime that strips types from TypeScript and runs the result on JavaScriptCore instead of V8.

Bun vs Node: runtime, tooling and alternatives compared

If that measurement favours Bun, the reason is usually consolidation rather than raw speed. A typical Node service assembles a runtime, a package manager, a test runner, a TypeScript transpiler and a bundler, and each one has its own config and upgrade cycle. Bun ships all of these as one binary. It runs .ts files directly and installs packages quickly, so CI scripts and onboarding get shorter.

Dimension Bun Node.js plus separate tools
Engine JavaScriptCore V8
TypeScript Runs .ts directly, no type checking Recent versions strip erasable types; otherwise a transpiler such as tsc
Package management bun install, reads package.json npm or another package manager
Test running bun test, Jest-compatible API Jest, Vitest or node:test
Node API coverage Broad, not complete Complete by definition
Observability APM agents that hook Node internals may be partial or unsupported First-class support from most vendors
Maturity 1.0 released in 2023 Production use since the early 2010s
Rate of change Frequent releases, behaviour still shifting LTS lines with predictable schedules

The observability row matters more than it looks. Tracing agents often depend on Node-specific module loading and async-context hooks. If your vendor's agent does not support Bun, you lose visibility, and that costs more than any runtime gain.

Vendor benchmarks measure particular scenarios, usually trivial handlers. Real services mostly wait on databases and the network.

Illustrative estimate (assumed numbers, not a benchmark): saving = runtime overhead × improvement ÷ total latency. With a 60 ms request, 3 ms of runtime overhead and a 50% improvement, that is 3 × 0.5 ÷ 60 = 2.5%.

Writing a Bun server with Bun.serve

Code below the example shows the two styles side by side. In Bun, Bun.serve takes an options object with a port and a fetch handler. The handler gets a Web-standard Request and returns a Response, or a promise of one. That is the same contract the Fetch API uses in browsers, so headers, URL parsing and body reading (await req.json(), req.headers.get(...)) all use APIs you already know.

Node's http.createServer works the other way around. The callback gets an IncomingMessage and a ServerResponse, and you build the reply step by step: set a status and headers with res.writeHead, then finish with res.end. The concepts line up one to one. Routing on req.url, setting a status, writing a body. The difference is that Bun's handler returns a value, while Node's changes a stream object. A returned Response is easier to test, because you can call the handler directly and inspect the result without a socket.

Frameworks such as Express are built on the node:http objects, not on Request/Response. Bun implements node:http, so these frameworks usually run on Bun unchanged. They go through Bun's compatibility layer, though, not the native Bun.serve path.

That is the lock-in trade-off. Code that calls Bun.serve or other Bun.* globals won't run on Node without changes. If you keep business logic in plain (Request) => Response functions, or stick to node:* modules, you keep a way back to Node, since Node also ships Request and Response as globals. The cost is a thin adapter layer and giving up some of Bun's built-in conveniences.

// server.bun.ts: runs on Bun only, because Bun.serve is a Bun global.
// The handler takes a Web-standard Request and returns a Response.
Bun.serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url);
    if (req.method === "POST" && url.pathname === "/echo") {
      const body = await req.json(); // Same body-reading API as the browser Fetch API
      return Response.json({ received: body });
    }
    return new Response("Not found", { status: 404 });
  },
});

// server.node.ts: the same routes with node:http.
// It runs on Node, and also on Bun through Bun's node:http compatibility layer.
import http from "node:http";

http
  .createServer((req, res) => {
    // req.url is only the path, so give URL a base to parse it
    const url = new URL(req.url ?? "/", "");
    if (req.method === "POST" && url.pathname === "/echo") {
      let raw = "";
      req.on("data", (chunk) => (raw += chunk)); // Collect the body from the stream
      req.on("end", () => {
        // Write the reply step by step: status and headers first, then the body
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ received: JSON.parse(raw) }));
      });
      return;
    }
    res.writeHead(404, { "Content-Type": "text/plain" });
    res.end("Not found");
  })
  .listen(3000);

The same POST /echo endpoint and 404 fallback written twice: Bun.serve returns a Response, while node:http builds the reply step by step on a ServerResponse stream.

Checking Node.js compatibility against your own dependencies

Bun's documentation describes the runtime as "designed as a drop-in replacement for Node.js." Read that as a design goal. Bun implements many node:* modules and Node globals, but coverage is incomplete. The gaps show up in code paths that compilers and installers never check.

The riskiest category is native addons: packages built with N-API or compiled through node-gyp. bun install can finish cleanly, and the failure only appears when the .node binary loads or calls a symbol Bun doesn't provide. A second category is libraries that depend on undocumented Node behavior, such as stream timing, the order of process events, or private internals reached through require. These usually don't crash at startup. They break under load or in one rarely used branch.

Verify against your own tree

  1. Run your existing test suite under Bun. If you use Jest or Vitest, bun run --bun test forces package scripts onto the Bun runtime. Without --bun, a CLI with a node shebang may still run on Node. Record every failing test and group the failures by dependency, not by test file.
  2. Start the real service entry point with bun run. Use production-like config and send it traffic. Tests rarely cover graceful shutdown, signal handling, TLS setup or database driver pooling.
  3. List the native modules. In node_modules, look for binding.gyp files, compiled .node files, a "gypfile": true field in a package's package.json, or dependencies on node-gyp-build, prebuild-install or node-addon-api. Each one needs an explicit pass or fail result.

The output is a short list of known blockers for your service, which is more useful than a general compatibility percentage.

#!/usr/bin/env bash
# Check one service against Bun instead of relying on a general compatibility claim.
# Run this from the project root after `bun install`.
set -uo pipefail

mkdir -p bun-audit

# 1. Run the existing test suite on the Bun runtime.
#    --bun makes CLIs with a `node` shebang, such as jest or vitest, run on Bun as well.
#    Keep the full log so you can group failures by dependency later.
bun run --bun test 2>&1 | tee bun-audit/test.log
echo "test exit code: ${PIPESTATUS[0]}" | tee -a bun-audit/test.log

# 2. Find native addons. Each result needs its own pass or fail check under Bun.
{
  echo "== binding.gyp (node-gyp build sources) =="
  find node_modules -type f -name binding.gyp | sort

  echo "== compiled .node binaries =="
  find node_modules -type f -name '*.node' | sort

  echo "== package.json with gypfile or a native build helper =="
  # Matches "gypfile": true or a dependency on node-gyp-build, prebuild-install or node-addon-api.
  # The helper packages also match their own name field. Ignore those entries.
  grep -rlE --include=package.json \
    '"gypfile": *true|"(node-gyp-build|prebuild-install|node-addon-api)"' \
    node_modules | sort -u || true
} | tee bun-audit/native-modules.txt

# 3. Next, start the real entry point with production-like config and send it traffic,
#    for example: bun run src/server.ts
#    Test shutdown, signals, TLS and database pooling there, because unit tests rarely cover them.

This shell script runs the existing test suite on the Bun runtime and saves the results. It then searches node_modules for native addons (binding.gyp files, compiled .node binaries and gyp-related package.json fields), producing a list of possible blockers for this service to check one by one.

How to adopt Bun incrementally: tooling first, runtime second

With that blocker list in hand, migrate in four steps. Each step can be reverted without undoing the others.

  1. Tooling only. Use bun install and bun run for local development and CI, while production keeps running on Node. Install speed improves, and the risk stays low because the code paths that serve traffic do not change.
  2. Tests. Move the suite to bun test, and keep Jest running in parallel for a while. Some Jest mocking APIs, config options and plugins may behave differently or not exist in Bun. A test that passes under one runner and fails under the other shows you exactly where those gaps are.
  3. Staging on the Bun runtime. Run the service under Bun in staging with production-like traffic and configuration. Watch error rates, memory use and native-addon behavior.
  4. Production. Promote once staging is stable. Keep the Node deployment path available so you can roll back.

Lockfile. Pick one lockfile and delete the others. Either standardize on Bun's lockfile or keep npm's and use Bun only as a script runner. If developers use different package managers, each one resolves the dependency tree on its own. The lockfiles then drift apart, and "works on my machine" bugs follow.

Version pinning. Pin an exact Bun version in CI and in your container base images. Read the release notes before each upgrade, because compatibility fixes can change behavior your code relies on.

One runtime in CI. Make CI test against the runtime you actually deploy. If tests run on Bun but production runs on Node, or the reverse, you get split-brain bugs that no pipeline will catch.

# .github/workflows/ci.yml
# Steps 1-2: Bun handles installs and scripts, bun test runs alongside Jest,
# and production stays on Node until staging proves the Bun runtime.
name: ci
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Pin an exact Bun version. Bump it on purpose, after reading the release notes.
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: "1.2.0"  # placeholder: use the version you have tested

      # Node matches the runtime production still deploys on.
      - uses: actions/setup-node@v4
        with:
          node-version: "20"

      # Bun's lockfile is the single source of truth. Fail if it would change.
      - run: bun install --frozen-lockfile

      # New runner, under migration.
      - name: bun test
        run: bun test

      # Old runner stays authoritative. It runs on Node, the deployed runtime,
      # so CI covers what production actually runs.
      - name: jest (Node)
        run: npx jest

A GitHub Actions job that pins an exact Bun version, installs from a frozen Bun lockfile, and runs bun test alongside Jest on Node, the runtime production still uses.

When to use Bun on the backend and when to stay on Node

Where Bun fits well

New TypeScript services. Bun runs TypeScript directly, so a greenfield service has no transpile step or loader configuration to maintain. Because nothing is inherited, you are not betting on compatibility with years of accumulated dependencies.

Internal tools and CLIs. Short-lived processes benefit most from fast startup, and an internal tool that misbehaves has a small blast radius.

CI pipelines dominated by installs and tests. If most of a pipeline's wall-clock time goes to dependency installs and test runs, bun install and bun test target exactly that time. You can adopt them without changing what runs in production.

Teams that want one toolchain. A single binary for running, installing, testing, and bundling means fewer tool versions to pin and fewer config files that can drift apart.

Where Node is the safer choice

Stay on Node for services that depend on native addons or on rarely used node:* APIs, because those are where compatibility gaps tend to show up. The same applies if your APM or tracing agents, or your internal platform tooling, assume V8 and Node internals.

Some engine-specific practices are lost entirely when you switch. Bun uses JavaScriptCore, so V8 flags, heap snapshot workflows, and the profiling habits your team has built do not carry over.

Operational experience matters too. Fewer engineers have debugged Bun under production load, so during an incident there is less shared knowledge to draw on, both inside your team and in public write-ups. If predictable operations matter more to a service than startup or install speed, that should decide it.

Impact on development cost and engineering time, plus a decision checklist

The savings come mostly from consolidation, while the costs are front-loaded and then recur with every upgrade. Bun replaces a package manager, test runner, bundler and TypeScript transpiler with one binary, which means fewer config files to reconcile and fewer tool versions to bump together. Bun releases frequently, though, so the compatibility work you do once has to be repeated, at least in part, each time you upgrade.

Area Where time is saved Where time is spent
Tooling One binary instead of several tools and their configs Re-testing dependencies after each Bun upgrade
TypeScript No separate transpile step to run code Type checking still needs its own CI step
CI Possibly faster installs and test runs Initial compatibility verification
Operations Little Observability gaps and debugging JavaScriptCore instead of V8

Illustrative estimate (assumed numbers, not a benchmark). Use this formula: minutes saved per month = pipeline runs per month × (current job minutes − Bun job minutes). Assume 1,500 runs, an 8-minute job today and a 6-minute job under Bun. That gives 1,500 × 2 = 3,000 CI minutes per month. Next, estimate the engineering hours spent on migration and on each upgrade, and check whether the CI savings cover them within a time horizon you care about. Replace every input here with figures measured on your own pipeline.

Before switching the runtime, confirm:

  • Your dependency test suite has a known pass rate under Bun, and every failure has been explained.
  • You know which native modules (node-gyp or N-API addons) are present and have tested them.
  • Your APM, tracing and profiling agents support Bun.
  • Your deploy target, including base images and serverless platforms, can run the Bun binary.
  • Someone on the team can debug memory and CPU issues on JavaScriptCore.
  • A tested rollback to Node exists, with a known time to complete it.

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 backend, 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.