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.
| Capability | Command or API | What it replaces |
|---|---|---|
| Runtime | bun ./src/index.ts | node, ts-node, tsx |
| Package manager | bun install, bun add | npm, yarn, pnpm |
| Script runner | bun run build | npm run |
| Test runner | bun test | Jest, Vitest, node --test |
| Bundler | bun build | esbuild, Rollup, webpack |
| Dev server | bun --hot, HTML imports | nodemon, parts of Vite |
| Package executor | bunx | npx |
| Workspaces | workspaces in package.json, --filter | npm/pnpm/Yarn workspaces |
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'sserializeanddeserializeuse 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
.nodefile built for V8 internals is not portable. - Heap snapshots,
--profoutput 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.
# 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 bununzip available. Kernel 5.6 or newer is recommended.# Windows PowerShell
powershell -c "irm bun.sh/install.ps1|iex"
# Windows with Scoop
scoop install bundocker pull oven/bun:debian
docker pull oven/bun:slim
docker pull oven/bun:alpine
docker pull oven/bun:distrolesslatest.# 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 gainedbun --revision prints the commit, which is what you want in a bug report.First steps
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 itbun 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.
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}`);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.
PORT=3000
DATABASE_URL=postgres://localhost:5432/notesconst port = Number(process.env.PORT ?? 3000);
// Bun.env is an alias for process.env if you prefer itbun 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 bunA 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
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{
"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.
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.
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.
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
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}`);error handler is the last line of defence - it keeps stack traces out of responses.Tests
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);
});
});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);
});fetch is global and the server exposes its own URL.Running and shipping it
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-apiFROM 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"]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.
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 cachebun 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_modulesbun 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.
bun install --linker isolated # strict, catches phantom dependencies
bun install --linker hoisted # traditional flat node_modulesTesting
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.
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 runnerThe 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
# 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.
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-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.
{
"name": "acme",
"private": true,
"workspaces": ["apps/*", "packages/*"]
}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 rootBun 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.
- 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 buildbun-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.
| Framework | Position as of August 2026 |
|---|---|
| Hono | Bun is a first-class target. Runs on Bun, Node.js, Deno and edge runtimes from one codebase. |
| Elysia | Built for Bun specifically. Fast, and tied to the runtime by design. |
| Express | Runs under Bun for typical applications. Middleware reaching into Node.js internals is where problems appear. |
| React | No runtime dependency. bun install and bun build both work; Bun can also bundle a React app through HTML imports. |
| Astro | bun install and bun run build are common and well supported. Check adapter behaviour for your deployment target. |
| Vite projects | Vite 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.js | Installs 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. |
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
| Area | Bun | Node.js |
|---|---|---|
| Engine | JavaScriptCore | V8 |
| TypeScript | Runs directly, types stripped not checked | Type stripping available in current versions |
| Install speed | Native installer, global content cache | Slower on cold installs of large trees |
| Test runner | Built in, Jest-shaped, parallel and sharding | node --test built in, or Jest/Vitest |
| Bundler | Built in, plus single-file executables | External - esbuild, Rollup, webpack |
| Native addons | Node-API supported; V8 C++ API partial | The reference target for every addon |
| Support policy | Frequent minor releases, no published LTS line | LTS lines with dated end-of-support |
| Hosting | Anywhere you control the runtime | First-class on essentially every platform |
| Observability | Inspector partially implemented; verify your APM | Mature profilers, debuggers and agents |
| Ecosystem depth | Runs npm packages; smaller body of operational knowledge | Fifteen 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.
| Dimension | Bun | Deno |
|---|---|---|
| Engine | JavaScriptCore | V8 |
| Security model | No permission sandbox | Permissions denied by default, granted per flag |
| Node.js compatibility | Primary design goal - drop into existing projects | Supported through a compatibility layer and npm: specifiers |
| TypeScript | Runs directly, no type checking at runtime | Runs directly, type checks by default |
| Packages | npm registry and node_modules | npm, JSR and URL imports |
| Tooling | Install, test, bundle, compile | Format, lint, test, bundle, compile |
| Best fit | Migrating or extending a Node.js codebase | Greenfield 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.
| Tool | Its job | How Bun overlaps | Where it still wins |
|---|---|---|---|
| Node.js | Runtime | Bun is an alternative runtime | Ecosystem depth, LTS, native addons, hosting |
| Deno | Runtime with sandbox | Both integrate their tooling | Permission model, type checking by default |
| npm | Package manager | bun install is a faster client for the same registry | Universality - it is already everywhere |
| Yarn | Package manager | Same role, faster installs | Plug'n'Play, established enterprise workflows |
| pnpm | Package manager | Bun's isolated linker gives the same strictness | Longer track record with strict layouts at scale |
| Vite | Dev server and frontend build | Bun bundles and serves HTML entrypoints | Plugin ecosystem, HMR maturity, framework integrations |
| esbuild | Bundler | bun build fills the same slot | A single focused job, used and hardened everywhere |
| Jest | Test runner | bun test implements much of its API | Complete API surface, mature mocking, DOM ecosystem |
| Vitest | Test runner | Both are fast and TypeScript-native | Vite integration, component testing, broader Jest parity |
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.
- Install Bun alongside Node.js. It lives in
~/.bunand changes nothing about your existing setup. - Branch. Keep
package-lock.jsonin git history until you are done, so reverting is one command. - Run
bun installand commitbun.lock. Your project still runs on Node.js at this point - this step alone is often worth shipping on its own. - Grep for
binding.gypandnode-gypin the dependency tree and test those packages under Bun first. This is where migrations fail, so find out early. - Run the test suite with
bun test. Note what breaks and whether it is your code or Jest-specific behaviour. - Run the application with
bunin development and exercise the paths that touchfs,crypto,tls,child_process,worker_threadsand streams. - Check
.envloading and confirm every variable your process expects is present, since Bun's automatic loading differs from adotenvcall you may be able to remove. - Verify your observability stack attaches. Logs, traces, metrics, profiler - under Bun, in a staging environment.
- Benchmark the real application under realistic load. Public benchmark numbers describe someone else's workload, not yours.
- Update CI: pin the Bun version, switch to
bun ci, keeptsc --noEmitin the pipeline. - Roll out gradually. One low-traffic service first, watched for a full business cycle, before anything that matters.
- Write the rollback down before you need it. Node.js plus the old lockfile, and a documented trigger for using it.
| Symptom | Likely cause | What to do |
|---|---|---|
Cannot find module for a package that installs fine | Native addon compiled against V8 internals | Look for a pure-JS or Bun-native replacement, or keep that service on Node.js |
Tests fail only under bun test | Jest API surface not fully implemented | Rewrite against bun:test, or keep Jest and use Bun only to install |
| Deserialising data written by Node.js fails | node:v8 uses JSC's wire format under Bun | Use JSON or another portable format across the boundary |
| APM agent reports nothing | node:inspector is partially implemented | Confirm support with your vendor before migrating the service |
| Works locally, fails in CI | Different Bun version, or bun install instead of bun ci | Pin the version and use bun ci everywhere |
| Subtly different error handling | Error messages and stack formats differ from V8 | Match on error codes and types, never on message text |
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.
| Workload | Assessment |
|---|---|
| Personal projects and prototypes | Yes. The developer experience is the reason to be here. |
| Internal tools and scripts | Yes. Low blast radius, immediate speed benefit. |
| CLI distribution | Yes, and it is a genuine differentiator - --compile ships one binary with no runtime install. |
| Monorepo tooling only | Yes. bun install and bun test in a Node.js monorepo is the lowest-risk adoption there is. |
| Greenfield HTTP APIs | Yes, if you control the dependency set and have verified your monitoring. |
| Server-rendered applications | Usually, but verify your framework's server on the Bun runtime rather than assuming. |
| Serverless functions | Where the platform supports it. V8-only platforms cannot run Bun at all. |
| Long-running services with native dependencies | Only after testing those dependencies specifically. This is the common failure point. |
| Enterprise and regulated systems | Not without a compliance review covering the absent LTS line and your observability gaps. |
| Mission-critical systems with no failover | Node.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.
[install]
# only resolve versions published at least three days ago
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@types/node", "typescript"]bun.lock are unaffected.{
"trustedDependencies": ["sharp"]
}- Commit
bun.lockand enforce it withbun ci. An unpinned dependency tree is an unreviewed one. - Keep secrets in the environment or a secret manager. Bun reads
.envautomatically, 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
bunuser 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,dotenvand a bundler config from a small project. - You want a faster npm client without changing anything else -
bun installon 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.
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
- Bun documentation - runtime, package manager, test runner, bundler and Node.js compatibility reference
- oven-sh/bun releases - version history and install commands
- Bun installation guide - platform requirements and Docker images
- bun install reference - lockfile, linkers,
minimumReleaseAge,bun ci - Bun test runner - Jest compatibility scope, parallel execution and sharding
- Node.js compatibility - per-module implementation status
- Bun 1.3 release notes - routing, full-stack builds and the SQL client
- Anthropic acquires Bun - acquisition announcement, December 2025



