Skip to content
NexiferLabs
All services
Solutions overview
Browse the library
About Nexifer
bunjavascripttypescriptnodejstooling

Bun in production: an honest evaluation of the all-in-one runtime

A practical look at Bun 1.3.14 - what the all-in-one JavaScript runtime does well, where it still breaks, and how to decide if it belongs in production.

T

team

23 min read
A stylised illustration of a bun with a bite taken out of it, sitting on a plate.

Bun is a JavaScript and TypeScript runtime that also ships a package manager, a bundler, a test runner and a script runner in the same binary. The question worth asking in 2026 is no longer whether it is fast. It is whether the parts you would depend on are finished.

This is a working evaluation rather than an introduction. It covers what Bun replaces, what it does not replace yet, how to install and use it, a complete REST API you can run, and the specific checks worth doing before you put it under a production workload.

What Bun actually is

Most JavaScript toolchains are a stack of separate programs: a runtime, an installer, a transpiler, a bundler, a test runner. Each one parses your source again, each one has its own config file, and each one is itself a JavaScript program. Bun collapses that into a single native executable where the parser is shared across the runtime, the bundler and the test runner.

CapabilityCommand or APIWhat it replaces
Runtimebun ./src/index.tsnode, ts-node, tsx
Package managerbun install, bun addnpm, yarn, pnpm
Script runnerbun run buildnpm run
Test runnerbun testJest, Vitest, node --test
Bundlerbun buildesbuild, Rollup, webpack
Dev serverbun --hot, HTML importsnodemon, parts of Vite
Package executorbunxnpx
Workspacesworkspaces in package.json, --filternpm/pnpm/Yarn workspaces
Each piece works on its own - bun install and bun test both run fine in a Node.js project.

TypeScript, JSX and TSX run directly with no build step and no loader flag. Bun strips types rather than type-checking them, so you still run tsc --noEmit in CI for type safety. It loads .env files automatically, and implements the web APIs you would expect at this point: fetch, Request, Response, Headers, URL, WebSocket, ReadableStream, crypto, structuredClone.

On top of that sit Bun's own APIs: Bun.serve for HTTP and WebSockets, Bun.file for file I/O, Bun.password for hashing, bun:sqlite for an embedded database, and Bun.SQL for PostgreSQL, MySQL and SQLite. These are the fastest path on Bun and the part that ties you to it. More on that trade later.

Who builds it now

Bun was created by Jarred Sumner in 2021 and reached 1.0 in September 2023. In December 2025, Anthropic announced it had acquired Bun, framing the deal as a way to accelerate Claude Code. The project stayed MIT-licensed, the team continued, and development remained public on GitHub.

For a team evaluating Bun, the governance question is a real one and cuts both ways. Bun now has a well-funded owner with a direct operational stake in it not breaking. It also no longer has independent stewardship of the kind the OpenJS Foundation gives Node.js, and its roadmap is shaped by one company's priorities. Which of those matters more depends on your risk posture, not on a technical fact.

How it works underneath

Bun runs on JavaScriptCore, the engine in WebKit and Safari, rather than V8. Most of Bun itself is written in Zig with C++ bindings into JSC. That one choice explains a surprising amount of both its strengths and its rough edges.

Why the engine choice matters

JavaScriptCore starts faster than V8 and uses a different tiering strategy for optimisation. Standard ECMAScript behaves identically - your application code does not care which engine it runs on. What differs is everything that reaches past the language spec into engine internals.

  • node:v8's serialize and deserialize use JSC's wire format, not V8's, so bytes serialised under Node will not deserialise under Bun.
  • Native addons compiled against V8's C++ ABI are a different binary interface. Bun implements Node-API and a growing subset of V8's C++ API, but a .node file built for V8 internals is not portable.
  • Heap snapshots, --prof output and V8-specific profiling tools do not carry across.
  • Error message wording and stack trace formatting differ in places, which matters if you parse them.

Where the speed comes from

Bun's performance is not one trick. It comes from removing layers:

The transpiler, bundler, resolver and test runner are native code sharing one parser, so a file is not re-parsed by four different JavaScript programs. Process startup does the minimum work needed before evaluating your entry point. bun install is a native installer that copies packages with the fastest system call available on the platform - clonefile on macOS, hardlink on Linux - with a global content cache at ~/.bun/install/cache and a binary cache for registry metadata. Running bun test instead of Jest removes a whole transform pipeline from the critical path rather than making that pipeline faster.

Installing Bun

Bun ships as a single dependency-free executable for macOS, Linux and Windows. It installs into ~/.bun and does not touch an existing Node.js installation.

bash
# macOS, Linux, WSL
curl -fsSL https://bun.com/install | bash

# macOS or Linux with Homebrew
brew install oven-sh/bun/bun

# Anywhere npm already exists - useful on CI runners
npm install -g bun
Linux needs unzip available. Kernel 5.6 or newer is recommended.
powershell
# Windows PowerShell
powershell -c "irm bun.sh/install.ps1|iex"

# Windows with Scoop
scoop install bun
Windows 10 version 1809 or later is required.
bash
docker pull oven/bun:debian
docker pull oven/bun:slim
docker pull oven/bun:alpine
docker pull oven/bun:distroless
Official images cover Linux x64 and arm64. Pin an exact tag in production, never latest.
bash
# Confirm the install and see the exact build
bun --version
bun --revision

# Upgrade
bun upgrade

# If you installed through a package manager, upgrade with that instead
brew upgrade bun
scoop update bun

# Remove a script-based install
rm -rf ~/.bun
# then delete the ~/.bun/bin line your shell rc file gained
bun --revision prints the commit, which is what you want in a bug report.

First steps

bash
mkdir notes-api && cd notes-api
bun init -y

bun add zod
bun add -d @types/bun

bun run dev          # runs the "dev" script in package.json
bun ./src/index.ts   # runs TypeScript directly, no build step
bunx cowsay hello    # runs a package binary without installing it
bun init writes package.json, a tsconfig.json set up for Bun, an entry file and a .gitignore.

A server is five lines. Bun.serve takes a routes object with static paths, parameterised paths and per-method handlers, and req.params is typed from the route pattern.

src/index.ts
const server = Bun.serve({
  port: 3000,
  routes: {
    "/health": new Response("ok"),
    "/hello/:name": (req) => Response.json({ hello: req.params.name }),
  },
  fetch: () => new Response("Not Found", { status: 404 }),
});

console.log(`Listening on ${server.url}`);
The routes option needs Bun 1.2.3 or later. Before that, everything went through fetch.

Environment variables need no library. Bun reads .env, .env.local and the mode-specific variants automatically, and exposes them on process.env.

.env
PORT=3000
DATABASE_URL=postgres://localhost:5432/notes
ts
const port = Number(process.env.PORT ?? 3000);
// Bun.env is an alias for process.env if you prefer it
bash
bun run --hot src/index.ts    # hot reload, process keeps its state
bun run --watch src/index.ts  # full restart on change
bun test                      # run the test suite
bun build src/index.ts --outdir dist --target bun

A complete REST API

Small enough to read in one sitting, complete enough to run. It uses Bun-native APIs where they earn their place and keeps the business logic free of them.

Layout

text
notes-api/
├── package.json
├── tsconfig.json          # written by `bun init`
├── .env
├── src/
│   ├── server.ts          # HTTP layer only
│   ├── config.ts          # validated environment
│   ├── notes.ts           # storage, swappable
│   └── validate.ts        # request validation
└── test/
    ├── validate.test.ts
    └── server.test.ts
package.json
{
  "name": "notes-api",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "bun --hot src/server.ts",
    "start": "bun src/server.ts",
    "test": "bun test",
    "check-types": "tsc --noEmit",
    "build": "bun build src/server.ts --compile --minify --sourcemap --outfile dist/notes-api"
  },
  "devDependencies": {
    "@types/bun": "latest",
    "typescript": "^5.9.2"
  }
}

Configuration

Read the environment once, validate it at startup, and fail loudly. A server that boots with a missing variable and discovers it on the first request is worse than one that refuses to boot.

src/config.ts
function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required environment variable: ${name}`);
  return value;
}

export const config = {
  port: Number(process.env.PORT ?? 3000),
  nodeEnv: process.env.NODE_ENV ?? "development",
  apiToken: required("API_TOKEN"),
} as const;

if (!Number.isInteger(config.port) || config.port <= 0) {
  throw new Error(`PORT must be a positive integer, received: ${process.env.PORT}`);
}

Validation

Untrusted input gets validated at the boundary and everything inside works with a typed value. This is hand-rolled to keep the example dependency-free; a schema library such as Zod installs and runs on Bun exactly as it does on Node.js, and is the better choice once you have more than a couple of shapes.

src/validate.ts
export type NoteInput = { title: string; body: string };

export type Parsed<T> =
  | { ok: true; value: T }
  | { ok: false; message: string };

export function parseNoteInput(input: unknown): Parsed<NoteInput> {
  if (typeof input !== "object" || input === null) {
    return { ok: false, message: "Body must be a JSON object." };
  }

  const { title, body } = input as Record<string, unknown>;

  if (typeof title !== "string" || title.trim().length === 0) {
    return { ok: false, message: "title is required and must be a non-empty string." };
  }
  if (title.length > 120) {
    return { ok: false, message: "title must be 120 characters or fewer." };
  }
  if (typeof body !== "string") {
    return { ok: false, message: "body is required and must be a string." };
  }

  return { ok: true, value: { title: title.trim(), body } };
}

Storage

An in-memory Map here. Swapping it for bun:sqlite or Bun.SQL changes this file and nothing else, which is the point of keeping it separate.

src/notes.ts
import type { NoteInput } from "./validate";

export type Note = NoteInput & { id: string; createdAt: string };

const notes = new Map<string, Note>();

export function listNotes(): Note[] {
  return [...notes.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}

export function getNote(id: string): Note | undefined {
  return notes.get(id);
}

export function createNote(input: NoteInput): Note {
  const note: Note = {
    id: crypto.randomUUID(),
    createdAt: new Date().toISOString(),
    ...input,
  };
  notes.set(note.id, note);
  return note;
}

The server

src/server.ts
import { config } from "./config";
import { createNote, getNote, listNotes } from "./notes";
import { parseNoteInput } from "./validate";

function fail(status: number, message: string): Response {
  return Response.json({ error: { status, message } }, { status });
}

export const server = Bun.serve({
  port: config.port,

  routes: {
    "/health": new Response("ok"),

    "/api/notes": {
      GET: () => Response.json({ data: listNotes() }),

      POST: async (req) => {
        let payload: unknown;
        try {
          payload = await req.json();
        } catch {
          return fail(400, "Request body must be valid JSON.");
        }

        const parsed = parseNoteInput(payload);
        if (!parsed.ok) return fail(422, parsed.message);

        const note = createNote(parsed.value);
        return Response.json({ data: note }, { status: 201 });
      },
    },

    "/api/notes/:id": {
      GET: (req) => {
        const note = getNote(req.params.id);
        return note ? Response.json({ data: note }) : fail(404, "Note not found.");
      },
    },
  },

  fetch: () => fail(404, "Route not found."),

  error(error) {
    console.error(error);
    return fail(500, "Unexpected server error.");
  },
});

console.log(`notes-api listening on ${server.url}`);
The error handler is the last line of defence - it keeps stack traces out of responses.

Tests

test/validate.test.ts
import { describe, expect, test } from "bun:test";
import { parseNoteInput } from "../src/validate";

describe("parseNoteInput", () => {
  test("accepts a valid note and trims the title", () => {
    const result = parseNoteInput({ title: "  Standup  ", body: "Notes" });
    expect(result.ok).toBe(true);
    if (result.ok) expect(result.value.title).toBe("Standup");
  });

  test("rejects a missing title", () => {
    expect(parseNoteInput({ body: "Notes" })).toMatchObject({ ok: false });
  });

  test.each([null, 42, "a string", []])("rejects %p", (input) => {
    expect(parseNoteInput(input).ok).toBe(false);
  });
});
test/server.test.ts
import { afterAll, expect, test } from "bun:test";
import { server } from "../src/server";

afterAll(() => server.stop(true));

test("POST then GET round-trips a note", async () => {
  const created = await fetch(new URL("/api/notes", server.url), {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ title: "First", body: "Hello" }),
  });

  expect(created.status).toBe(201);
  const { data } = await created.json();

  const fetched = await fetch(new URL(`/api/notes/${data.id}`, server.url));
  expect(fetched.status).toBe(200);
});

test("rejects an invalid body with 422", async () => {
  const response = await fetch(new URL("/api/notes", server.url), {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ body: "no title" }),
  });
  expect(response.status).toBe(422);
});
No supertest, no test HTTP client - fetch is global and the server exposes its own URL.

Running and shipping it

bash
API_TOKEN=dev-token bun run dev     # hot reload
bun test                            # suite
bun run check-types                 # tsc, because Bun strips types rather than checking them
bun run start                       # production process

# or produce a single binary with the runtime baked in
bun build src/server.ts --compile --minify --sourcemap --outfile dist/notes-api
./dist/notes-api
Dockerfile
FROM oven/bun:1.3.14-slim AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun ci --production

FROM oven/bun:1.3.14-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER bun
EXPOSE 3000
CMD ["bun", "src/server.ts"]
The official images ship a non-root bun user. Pin the tag.

Package management

bun install is the piece most teams adopt first, because it works in an ordinary Node.js project and changes nothing about how that project runs. It reads your existing package.json, resolves against the npm registry, and writes a bun.lock text lockfile. Prior to Bun 1.2 the lockfile was a binary bun.lockb; the text format is now the default and reviews properly in a pull request.

bash
bun install                       # install everything
bun add hono                      # dependency
bun add -d vitest                 # dev dependency
bun add -E zod@4.0.0              # exact version, no caret
bun remove lodash                 # uninstall and update package.json
bun update                        # update within semver ranges
bun update --latest react         # cross a major version deliberately
bun outdated                      # report without changing anything
bun pm cache rm                   # clear the global package cache
bash
bun ci                            # equivalent to --frozen-lockfile
bun install --frozen-lockfile     # fails if package.json and bun.lock disagree
bun install --production          # skip devDependencies
bun install --lockfile-only       # refresh the lockfile without touching node_modules
bun ci requires bun.lock to be committed. Use it in CI so a drifted lockfile fails the build.

Migration is mostly automatic. Point Bun at a project with a package-lock.json, yarn.lock or pnpm-lock.yaml and it converts to bun.lock on first install, leaving the original file alone. The pnpm path also migrates workspace globs, catalogs, overrides and patched dependencies into package.json.

Bun offers two node_modules layouts. Hoisted is the flat npm-style tree. Isolated is the pnpm-style layout with a store under node_modules/.bun and symlinks, which stops packages importing dependencies they never declared. New monorepos default to isolated; new single-package projects and anything created before 1.3.2 default to hoisted.

bash
bun install --linker isolated     # strict, catches phantom dependencies
bun install --linker hoisted      # traditional flat node_modules

Testing

bun test picks up files matching *.test.{js,jsx,ts,tsx} and *_spec.*, runs TypeScript and JSX without configuration, and exposes a Jest-shaped API from bun:test - describe, test, expect, the lifecycle hooks, mock, spyOn and snapshots.

bash
bun test                          # everything
bun test validate                 # only files matching "validate"
bun test --watch                  # rerun on change
bun test --coverage               # coverage report, LCOV output supported
bun test --bail                   # stop at the first failure
bun test --changed                # only files affected by your working changes
bun test --parallel               # one worker per core
bun test --isolate                # fresh global per file
bun test --shard=2/4              # one slice per CI runner

The parallel and sharding flags arrived in 1.3.13 and matter once a suite is large. --parallel distributes files across worker processes and isolates each file by default; --shard=i/n splits deterministically across CI machines with no coordinator, matching the syntax Jest, Vitest and Playwright already use.

Bundling and single-file executables

bash
# Browser bundle
bun build src/app.tsx --outdir dist --target browser --minify --sourcemap

# Server bundle for Node.js
bun build src/server.ts --outdir dist --target node --external pg

# Multiple entry points with code splitting
bun build src/index.ts src/worker.ts --outdir dist --splitting

# Inline a build-time constant
bun build src/index.ts --outdir dist --define process.env.NODE_ENV='"production"'

Tree shaking is always on. --target chooses between browser, bun and node. --external keeps a package out of the bundle. There is also a programmatic Bun.build() with the same options plus plugins.

--compile produces a standalone executable with the runtime embedded, and can cross-compile to another platform. It is what makes Bun attractive for CLI distribution: one file, no runtime install on the target machine.

bash
bun build src/cli.ts --compile --minify --sourcemap --outfile dist/mycli

# cross-compile from a macOS laptop to a Linux server
bun build src/cli.ts --compile --target=bun-linux-x64 --outfile dist/mycli-linux
Binaries include the runtime, so expect tens of megabytes. On older x64 CPUs use a -baseline target.

Monorepos

Bun reads the standard workspaces field, so an existing npm or Yarn monorepo needs no restructuring. A single bun install at the root resolves every package and links internal dependencies.

package.json
{
  "name": "acme",
  "private": true,
  "workspaces": ["apps/*", "packages/*"]
}
bash
bun install                              # every workspace
bun install --filter './apps/web'        # one package's dependencies
bun install --filter '!apps/docs'        # everything except one

bun --filter '*' build                   # run a script everywhere, in dependency order
bun --filter '@acme/api' dev             # run it in one package
bun add zod --filter '@acme/shared'      # add a dependency to one package from the root

Bun and Turborepo coexist without conflict - Bun installs and runs, Turbo caches and orchestrates. The rule that matters in CI is the one that always mattered: commit bun.lock, cache ~/.bun/install/cache keyed on its hash, and run bun ci rather than bun install.

.github/workflows/ci.yml
- uses: oven-sh/setup-bun@v2
  with:
    bun-version: 1.3.14
- run: bun ci
- run: bun run check-types
- run: bun test --coverage
- run: bun run build
Pin bun-version. A floating runtime version is an unpinned dependency.

Frameworks

Framework support splits into two questions that get conflated: does the framework's tooling install and build under Bun, and does the framework's server run on the Bun runtime? The first is nearly always yes. The second varies.

FrameworkPosition as of August 2026
HonoBun is a first-class target. Runs on Bun, Node.js, Deno and edge runtimes from one codebase.
ElysiaBuilt for Bun specifically. Fast, and tied to the runtime by design.
ExpressRuns under Bun for typical applications. Middleware reaching into Node.js internals is where problems appear.
ReactNo runtime dependency. bun install and bun build both work; Bun can also bundle a React app through HTML imports.
Astrobun install and bun run build are common and well supported. Check adapter behaviour for your deployment target.
Vite projectsVite runs under Bun. Bun does not replace Vite's plugin ecosystem or dev server, so most teams keep Vite and use Bun as the installer.
Next.jsInstalls and builds with Bun. Running the Next.js server on the Bun runtime is the part to verify against your version and features rather than assume.
A framework working under bun install is not the same claim as its server running on the Bun runtime.

Where Bun still falls short

This is the section that should decide your adoption plan. None of it is fatal; all of it is worth knowing before rather than after.

Native addons

Bun implements Node-API, which is the engine-independent interface and the recommended way to write native code for Bun. The problem is the packages that bypass it and use V8's C++ API directly. Bun has been implementing a subset of that API on top of JavaScriptCore since 1.1.25, but coverage is partial by nature. bun:ffi exists as an alternative and the documentation calls it experimental.

The practical check takes a minute: look for binding.gyp or node-gyp in your dependency tree and test those packages first. Common cases have Bun-native replacements - bun:sqlite instead of better-sqlite3, Bun.password instead of bcrypt.

Partial Node.js modules

Bun's own compatibility table marks several modules as partially implemented rather than complete. node:vm covers the core API and ES modules but is not a full reimplementation. node:worker_threads works, minus options including stdin, stdout, stderr and resourceLimits. node:inspector implements the Profiler API and little else. The long tail - exact fs error codes, undocumented TLS options, precise HTTP byte sequences - is where a large application finds its surprises.

Observability and debugging

This is the gap most likely to bite a production team and the one least discussed. Node.js APM agents are frequently built on V8 hooks and the full inspector protocol. With node:inspector only partially implemented, an agent may attach with reduced capability or not at all. Heap snapshots and V8 profiling output do not transfer. Before committing a service, confirm your monitoring stack actually works under Bun - not that the vendor lists Bun on a page.

Support lifecycle

Node.js publishes LTS lines with dated end-of-support. Bun does not publish an equivalent long-term support commitment, and minor releases land frequently. In practice that means upgrading is your security strategy: staying current is the supported path, and there is no back-ported patch line to sit on. Budget for it.

Lock-in

Bun.serve, bun:sqlite and Bun.SQL are good APIs and they only run on Bun. Using them is a legitimate choice, not a mistake - but it is a choice. Keeping them behind a thin interface at the edges of your application preserves the option to leave, and costs almost nothing while you are writing the code.

Ecosystem and team factors

  • Far fewer answered questions when something breaks at 2am. Node.js has fifteen years of accumulated failure modes documented publicly.
  • Managed platform support is narrower. Bun runs anywhere you control the runtime; V8-only platforms such as Cloudflare Workers cannot run it at all.
  • Onboarding a new engineer means teaching a second toolchain unless the whole team has moved.
  • Windows works and is supported, but Linux and macOS remain the better-trodden paths.

Bun and Node.js side by side

AreaBunNode.js
EngineJavaScriptCoreV8
TypeScriptRuns directly, types stripped not checkedType stripping available in current versions
Install speedNative installer, global content cacheSlower on cold installs of large trees
Test runnerBuilt in, Jest-shaped, parallel and shardingnode --test built in, or Jest/Vitest
BundlerBuilt in, plus single-file executablesExternal - esbuild, Rollup, webpack
Native addonsNode-API supported; V8 C++ API partialThe reference target for every addon
Support policyFrequent minor releases, no published LTS lineLTS lines with dated end-of-support
HostingAnywhere you control the runtimeFirst-class on essentially every platform
ObservabilityInspector partially implemented; verify your APMMature profilers, debuggers and agents
Ecosystem depthRuns npm packages; smaller body of operational knowledgeFifteen years of documented production experience

The honest summary: Bun is the better developer experience and Node.js is the lower operational risk. For a new HTTP API, a CLI or a monorepo's tooling, Bun's advantages are immediate and its weak points rarely apply. For a large existing service with native dependencies, an APM contract and a compliance review, Node.js is the conservative answer and conservative is often correct.

Bun and Deno

Both are post-Node runtimes with integrated tooling, and they answer a different question. Deno's defining feature is its permission model: file, network and environment access are denied unless granted with explicit flags. Bun has no equivalent sandbox. If your threat model includes running code you do not fully trust, that difference outranks everything else in this article.

DimensionBunDeno
EngineJavaScriptCoreV8
Security modelNo permission sandboxPermissions denied by default, granted per flag
Node.js compatibilityPrimary design goal - drop into existing projectsSupported through a compatibility layer and npm: specifiers
TypeScriptRuns directly, no type checking at runtimeRuns directly, type checks by default
Packagesnpm registry and node_modulesnpm, JSR and URL imports
ToolingInstall, test, bundle, compileFormat, lint, test, bundle, compile
Best fitMigrating or extending a Node.js codebaseGreenfield work where the sandbox is the point

In practice, teams with an existing Node.js codebase find Bun the shorter path because compatibility was the design goal from the start. Teams starting fresh who value the permission model tend to prefer Deno. Both are reasonable; they optimise for different things.

How Bun overlaps with the tools you already use

Comparing Bun to a bundler or a test runner as though they compete on equal terms misses what is going on. Bun overlaps with each of these tools in one dimension while doing something different in the others.

ToolIts jobHow Bun overlapsWhere it still wins
Node.jsRuntimeBun is an alternative runtimeEcosystem depth, LTS, native addons, hosting
DenoRuntime with sandboxBoth integrate their toolingPermission model, type checking by default
npmPackage managerbun install is a faster client for the same registryUniversality - it is already everywhere
YarnPackage managerSame role, faster installsPlug'n'Play, established enterprise workflows
pnpmPackage managerBun's isolated linker gives the same strictnessLonger track record with strict layouts at scale
ViteDev server and frontend buildBun bundles and serves HTML entrypointsPlugin ecosystem, HMR maturity, framework integrations
esbuildBundlerbun build fills the same slotA single focused job, used and hardened everywhere
JestTest runnerbun test implements much of its APIComplete API surface, mature mocking, DOM ecosystem
VitestTest runnerBoth are fast and TypeScript-nativeVite integration, component testing, broader Jest parity
Adopting Bun is not one decision. bun install alone is a low-risk change; moving the runtime is not.

Migrating an existing Node.js project

The order matters. Each step is reversible and each one gives you information before you commit to the next.

  1. Install Bun alongside Node.js. It lives in ~/.bun and changes nothing about your existing setup.
  2. Branch. Keep package-lock.json in git history until you are done, so reverting is one command.
  3. Run bun install and commit bun.lock. Your project still runs on Node.js at this point - this step alone is often worth shipping on its own.
  4. Grep for binding.gyp and node-gyp in the dependency tree and test those packages under Bun first. This is where migrations fail, so find out early.
  5. Run the test suite with bun test. Note what breaks and whether it is your code or Jest-specific behaviour.
  6. Run the application with bun in development and exercise the paths that touch fs, crypto, tls, child_process, worker_threads and streams.
  7. Check .env loading and confirm every variable your process expects is present, since Bun's automatic loading differs from a dotenv call you may be able to remove.
  8. Verify your observability stack attaches. Logs, traces, metrics, profiler - under Bun, in a staging environment.
  9. Benchmark the real application under realistic load. Public benchmark numbers describe someone else's workload, not yours.
  10. Update CI: pin the Bun version, switch to bun ci, keep tsc --noEmit in the pipeline.
  11. Roll out gradually. One low-traffic service first, watched for a full business cycle, before anything that matters.
  12. Write the rollback down before you need it. Node.js plus the old lockfile, and a documented trigger for using it.
SymptomLikely causeWhat to do
Cannot find module for a package that installs fineNative addon compiled against V8 internalsLook for a pure-JS or Bun-native replacement, or keep that service on Node.js
Tests fail only under bun testJest API surface not fully implementedRewrite against bun:test, or keep Jest and use Bun only to install
Deserialising data written by Node.js failsnode:v8 uses JSC's wire format under BunUse JSON or another portable format across the boundary
APM agent reports nothingnode:inspector is partially implementedConfirm support with your vendor before migrating the service
Works locally, fails in CIDifferent Bun version, or bun install instead of bun ciPin the version and use bun ci everywhere
Subtly different error handlingError messages and stack formats differ from V8Match on error codes and types, never on message text
The first two rows account for most failed migrations.

Is it ready for production?

That question has no single answer, which is why it generates so much noise. Bun has been stable since September 2023 and runs real workloads. The useful question is which workload.

WorkloadAssessment
Personal projects and prototypesYes. The developer experience is the reason to be here.
Internal tools and scriptsYes. Low blast radius, immediate speed benefit.
CLI distributionYes, and it is a genuine differentiator - --compile ships one binary with no runtime install.
Monorepo tooling onlyYes. bun install and bun test in a Node.js monorepo is the lowest-risk adoption there is.
Greenfield HTTP APIsYes, if you control the dependency set and have verified your monitoring.
Server-rendered applicationsUsually, but verify your framework's server on the Bun runtime rather than assuming.
Serverless functionsWhere the platform supports it. V8-only platforms cannot run Bun at all.
Long-running services with native dependenciesOnly after testing those dependencies specifically. This is the common failure point.
Enterprise and regulated systemsNot without a compliance review covering the absent LTS line and your observability gaps.
Mission-critical systems with no failoverNode.js remains the conservative answer, and conservative is the right instinct here.

Before a production deployment, the checklist is short and non-negotiable: the Bun version pinned in CI and in your images; bun.lock committed and bun ci enforcing it; the test suite passing under Bun; monitoring confirmed working; a load test against your own traffic shape; an upgrade cadence you have actually scheduled; and a rollback path someone has walked through.

Security

Bun makes two supply-chain decisions that are better than the npm default, and neither makes your application secure by itself.

The first: Bun does not run lifecycle scripts for installed dependencies. A postinstall script in a package you depend on does not execute unless you name that package in trustedDependencies. This closes one of the most direct paths a compromised package has to your machine and your CI runner.

The second: minimumReleaseAge filters out package versions published more recently than a threshold you set. Most malicious npm releases are caught and pulled within hours or days, so refusing to install anything published in the last three days removes a large share of that exposure at almost no cost.

bunfig.toml
[install]
# only resolve versions published at least three days ago
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@types/node", "typescript"]
Applies to new resolutions. Versions already pinned in bun.lock are unaffected.
package.json
{
  "trustedDependencies": ["sharp"]
}
An explicit allowlist for packages whose install scripts genuinely need to run.
  • Commit bun.lock and enforce it with bun ci. An unpinned dependency tree is an unreviewed one.
  • Keep secrets in the environment or a secret manager. Bun reads .env automatically, which makes it that much easier to commit one by accident - check your .gitignore.
  • Pin the Bun version in Docker images and CI. Upgrade deliberately, on a schedule, with the release notes read.
  • Run containers as the non-root bun user the official images provide, and prefer the slim or distroless variants.
  • Bun has no permission sandbox. If you need to execute untrusted code, that is a Deno question or a container question, not a Bun one.

When to use Bun, and when not to

Reach for it when

  • You are starting a new HTTP API or backend service and control the dependencies.
  • You are shipping a CLI and want one binary per platform with no runtime prerequisite.
  • Install time on a large monorepo is costing your team real hours every week.
  • Your test suite is slow and consists mostly of plain TypeScript logic.
  • You want to delete ts-node, nodemon, dotenv and a bundler config from a small project.
  • You want a faster npm client without changing anything else - bun install on its own is the easiest win here.

Stay on Node.js when

  • Your dependency tree includes native addons you cannot replace.
  • Your APM, profiler or debugger does not fully support Bun, and you need them.
  • You have a compliance or procurement process that requires a dated support lifecycle.
  • You deploy to a V8-only platform such as Cloudflare Workers.
  • The service is mission-critical, has no failover, and the current stack is not causing you pain.
  • Your team has no capacity to debug a second runtime's edge cases this quarter.

Verdict

Bun is good, and the specific thing it is good at is removing layers. One binary instead of six tools, one config surface instead of five, TypeScript that just runs. On a new project the difference in daily friction is large and immediate.

It is not better than Node.js in the way that phrasing implies. It is better at developer experience and worse at operational maturity, and which of those you are buying depends entirely on what you are building. Node.js has fifteen years of production failure modes documented by people who hit them first. That is not a feature you can ship past.

It is production-ready for a defined set of workloads: services where you control the dependency set, CLIs, internal tools, and monorepo tooling. It is not yet the obvious default for a large system with native dependencies, an APM contract and a compliance review - and the gap there is observability and support lifecycle, not speed.

Adopt the installer first, the test runner second, and the runtime last. Each step is reversible on its own, and you learn something before committing to the next.

Our standing recommendation to teams evaluating Bun

Whatever you decide, decide it against your own numbers. Run bun install on your real dependency tree, bun test on your real suite, and a load test on your real traffic shape. Those three results will tell you more than any comparison article, this one included.

Sources

Back to Blog
Share:

Related Posts