When Bun first appeared, the JavaScript ecosystem greeted it with a familiar mix of excitement and skepticism. Another runtime promising to be faster than Node.js? We had heard that pitch before with Deno, and while Deno carved out its niche, the majority of production Node.js applications never migrated. But Bun is a different proposition. Rather than reimagining the JavaScript runtime from philosophical first principles, Bun focuses on being a faster, more integrated version of the toolchain developers already use. It runs your existing package.json scripts, installs from the npm registry, and executes TypeScript files without a compilation step.
This guide covers what it takes to bring Bun from a local development curiosity to a production-grade runtime. We will walk through the bundler, the test runner, the package manager, real-world Node.js compatibility findings, and deployment patterns that have proven reliable under sustained traffic.
Understanding Bun's Architecture
Bun is built on JavaScriptCore (JSC), the engine that powers Safari and WebKit, rather than V8, which powers Node.js and Chrome. This is not just a trivia fact. It has practical implications for your production applications.
JSC compiles JavaScript through a multi-tier JIT pipeline: the LLInt interpreter handles cold code, the Baseline JIT compiles hot functions quickly, the DFG (Data Flow Graph) JIT optimizes further, and the FTL (Faster Than Light) JIT produces highly optimized machine code for the hottest paths. This tiered approach means Bun applications start executing meaningful code almost immediately, without the warmup delay that V8 applications experience while their optimizing compiler catches up.
Beyond the engine choice, Bun is written in Zig and C++, which gives it direct control over system calls and memory allocation. The HTTP server, for example, uses io_uring on Linux for asynchronous I/O rather than libuv's epoll-based event loop. This results in measurably higher throughput for request-heavy workloads.
// A basic Bun HTTP server — no framework needed
const server = Bun.serve({
port: 3000,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/health") {
return new Response("ok", { status: 200 });
}
if (url.pathname === "/api/data") {
return Response.json({
timestamp: Date.now(),
runtime: "bun",
version: Bun.version,
});
}
return new Response("Not Found", { status: 404 });
},
// Graceful error handling
error(error) {
console.error("Server error:", error);
return new Response("Internal Server Error", { status: 500 });
},
});
console.log(`Server running at http://localhost:${server.port}`);
Notice that Bun's serve() API uses the Web Standard Request and Response objects rather than Node.js-style req and res callback parameters. This is deliberate: Bun aligns with web platform standards wherever possible, which means skills transfer between Bun server code, Cloudflare Workers, and Deno Deploy.
The Package Manager: Speed Without Compromise
Bun's package manager is where most teams feel the immediate impact. Running bun install on a typical project with 500+ dependencies completes in under two seconds, compared to 15-30 seconds for npm. This is not a synthetic benchmark; it is what you see in CI pipelines and developer machines every day.
The speed comes from three architectural decisions. First, Bun resolves dependencies using a native algorithm written in Zig, bypassing the JavaScript overhead of npm's resolver. Second, it uses hardlinks to a global cache rather than copying files, so the same version of a package on disk is shared across all projects. Third, it downloads and extracts packages concurrently using system-level I/O primitives.
# Install all dependencies (reads package.json, writes bun.lockb)
bun install
# Add a production dependency
bun add zod @trpc/server @trpc/client
# Add a dev dependency
bun add -d typescript @types/node vitest
# Remove a dependency
bun remove lodash
# Install in CI (frozen lockfile, fails if lockfile is out of date)
bun install --frozen-lockfile
# Generate a yarn-compatible text lockfile for auditing
bun install --yarn
The lockfile format deserves discussion. Bun uses a binary lockfile (bun.lockb) by default, which is faster to read and write than JSON or YAML alternatives. For teams that need human-readable lockfiles for code review or security audits, the --yarn flag generates a yarn.lock alongside the binary lockfile. You can also configure this permanently in bunfig.toml:
# bunfig.toml
[install]
# Use a text-based lockfile for easier diffs
lockfile = "yarn"
# Set the global cache directory
globalDir = "~/.bun/install/cache"
# Registry configuration
registry = "https://registry.npmjs.org/"
For monorepo setups, Bun supports workspaces through the standard package.json workspaces field. It handles cross-workspace dependencies through symlinks and resolves workspace packages before checking the registry, matching the behavior developers expect from Yarn or pnpm workspaces.
The Bundler: Native Speed for Production Builds
Bun ships with a built-in bundler that competes directly with esbuild and Webpack. It handles TypeScript, JSX, tree shaking, code splitting, and CSS without additional configuration. For teams building type-safe full-stack applications, having the bundler integrated into the runtime eliminates a category of toolchain friction.
// build.ts — production bundle configuration
const result = await Bun.build({
entrypoints: ["./src/index.tsx"],
outdir: "./dist",
target: "browser",
format: "esm",
splitting: true,
minify: {
whitespace: true,
identifiers: true,
syntax: true,
},
sourcemap: "external",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
"process.env.API_URL": JSON.stringify("https://api.example.com"),
},
external: ["*.woff2", "*.png"],
naming: {
entry: "[name]-[hash].js",
chunk: "chunks/[name]-[hash].js",
asset: "assets/[name]-[hash][ext]",
},
});
if (!result.success) {
for (const message of result.logs) {
console.error(message);
}
process.exit(1);
}
console.log(`Built ${result.outputs.length} files`);
The bundler's code splitting works automatically when you use dynamic import() expressions. Each dynamically imported module becomes a separate chunk, and shared dependencies between chunks are extracted into common bundles. This is the same behavior you get from Webpack's splitChunks plugin, but it requires zero configuration.
For server-side bundles, switch the target to "bun" to produce a single executable file that includes all dependencies. This is particularly useful for deploying serverless functions or containerized applications where you want a minimal artifact:
# Bundle a server application into a single file
bun build ./src/server.ts --target=bun --outdir=./dist --minify
# The output is a self-contained script that runs with bun
bun ./dist/server.js
You can also compile your application into a standalone executable that does not require Bun to be installed on the target machine:
# Create a standalone executable
bun build ./src/server.ts --compile --outfile=myapp
# The resulting binary runs without Bun installed
./myapp
The Test Runner: Jest-Compatible and Fast
Bun includes a test runner that is compatible with Jest's API surface while running significantly faster. If your existing test suite uses describe, it, expect, and beforeEach, it will likely run under bun test without modification.
// __tests__/user-service.test.ts
import { describe, it, expect, beforeEach, mock } from "bun:test";
import { UserService } from "../src/services/user";
import { createTestDatabase } from "./helpers";
describe("UserService", () => {
let db: ReturnType<typeof createTestDatabase>;
let service: UserService;
beforeEach(() => {
db = createTestDatabase();
service = new UserService(db);
});
it("creates a user with valid input", async () => {
const user = await service.create({
name: "Test User",
email: "[email protected]",
});
expect(user.id).toBeDefined();
expect(user.name).toBe("Test User");
expect(user.createdAt).toBeInstanceOf(Date);
});
it("rejects duplicate emails", async () => {
await service.create({ name: "First", email: "[email protected]" });
expect(
service.create({ name: "Second", email: "[email protected]" })
).rejects.toThrow("Email already exists");
});
it("hashes passwords before storing", async () => {
const user = await service.create({
name: "Secure User",
email: "[email protected]",
password: "plaintext123",
});
const stored = await db.query("SELECT password_hash FROM users WHERE id = ?", [user.id]);
expect(stored.password_hash).not.toBe("plaintext123");
expect(stored.password_hash).toMatch(/^\$2[aby]\$/); // bcrypt hash
});
});
The test runner supports mocking through the mock() function from bun:test. Module mocking works similarly to Jest's jest.mock(), but with some differences in hoisting behavior. For most test suites, the migration is straightforward:
import { mock, it, expect } from "bun:test";
// Mock a module
mock.module("../src/email", () => ({
sendEmail: mock(() => Promise.resolve({ messageId: "test-id" })),
}));
// Mock a function
const logSpy = mock((message: string) => {});
it("logs when processing completes", async () => {
await processItems(logSpy);
expect(logSpy).toHaveBeenCalledWith("Processing complete");
});
// Snapshot testing also works
it("renders user card correctly", () => {
const html = renderUserCard({ name: "Ada Lovelace", role: "Engineer" });
expect(html).toMatchSnapshot();
});
For teams running large test suites, Bun's test runner shines in CI environments. Tests that take 45 seconds under Jest often complete in under 10 seconds with bun test, primarily because there is no transpilation step and no JIT warmup delay. Combined with the Playwright integration for end-to-end tests, this can cut your total CI pipeline time in half.
Node.js Compatibility in Practice
Bun implements the majority of the Node.js standard library, but "majority" is a word that hides important details when your production application depends on specific behaviors. Here is what works reliably, what has caveats, and what to watch out for.
The core modules that work without issues include fs (both callback and promise variants), path, os, url, crypto (standard hashing and encryption), buffer, stream (readable, writable, transform, and pipeline), events, http, https, net, tls, child_process, and util. These cover the vast majority of what typical web applications use.
The areas that require caution include native addons compiled with node-gyp, which may not work without recompilation using Bun's native addon support. The vm module has partial implementation. The worker_threads module works for basic usage but has edge cases around SharedArrayBuffer and Atomics. Some community packages that rely on undocumented V8 internals or Node.js-specific C++ APIs will not function.
// Testing Node.js compatibility before migration
// Run this script to check critical paths in your application
import { existsSync, readFileSync, writeFileSync } from "fs";
import { createHash, randomUUID } from "crypto";
import { pipeline } from "stream/promises";
import { createReadStream, createWriteStream } from "fs";
// File system operations
const tempFile = `/tmp/bun-compat-test-${randomUUID()}.txt`;
writeFileSync(tempFile, "Hello from Bun");
const content = readFileSync(tempFile, "utf-8");
console.assert(content === "Hello from Bun", "fs read/write works");
// Crypto operations
const hash = createHash("sha256").update("test").digest("hex");
console.assert(hash.length === 64, "crypto hashing works");
// Stream pipeline
await pipeline(
createReadStream(tempFile),
createWriteStream(`${tempFile}.copy`)
);
console.assert(existsSync(`${tempFile}.copy`), "stream pipeline works");
// SQLite (built into Bun — no npm package needed)
import { Database } from "bun:sqlite";
const db = new Database(":memory:");
db.run("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)");
db.run("INSERT INTO test VALUES (1, 'hello')");
const row = db.query("SELECT * FROM test WHERE id = 1").get();
console.assert(row.value === "hello", "bun:sqlite works");
console.log("All compatibility checks passed");
One of Bun's most useful built-in features for production applications is the native SQLite driver. Instead of installing better-sqlite3 (which requires native compilation) or sql.js (which runs SQLite in WebAssembly), Bun provides bun:sqlite with direct bindings to libsqlite3. This is faster than both alternatives and requires no additional dependencies.
The practical migration strategy is incremental. Start by switching your development environment to Bun while keeping Node.js in production. Run your test suite under both runtimes. Once all tests pass with Bun, deploy it to a canary environment and monitor for behavioral differences under real traffic. This approach catches compatibility issues early without risking your production deployment.
Production Deployment Patterns
Deploying Bun to production follows the same principles as any server-side application, with a few runtime-specific optimizations that are worth understanding.
The Docker setup is straightforward. Bun provides official images that are smaller than their Node.js equivalents because Bun ships as a single binary rather than an entire runtime directory tree:
# Dockerfile for Bun production deployment
FROM oven/bun:1.1-alpine AS base
WORKDIR /app
# Install dependencies in a separate layer for caching
FROM base AS deps
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production
# Build the application
FROM base AS build
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
# Production image
FROM base AS production
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./
# Run as non-root user
RUN adduser -D appuser
USER appuser
EXPOSE 3000
CMD ["bun", "run", "./dist/server.js"]
For graceful shutdown, Bun handles SIGTERM signals like Node.js. Your server should stop accepting new connections and finish processing in-flight requests before exiting. This is critical for Kubernetes deployments where the pod termination sequence sends SIGTERM before force-killing the process:
// Graceful shutdown handling
const server = Bun.serve({
port: parseInt(process.env.PORT || "3000"),
fetch: handleRequest,
});
let isShuttingDown = false;
process.on("SIGTERM", async () => {
console.log("SIGTERM received, starting graceful shutdown");
isShuttingDown = true;
// Stop accepting new connections
server.stop();
// Allow 10 seconds for in-flight requests to complete
await new Promise((resolve) => setTimeout(resolve, 10_000));
console.log("Shutdown complete");
process.exit(0);
});
// Health check that respects shutdown state
function handleRequest(req: Request): Response {
const url = new URL(req.url);
if (url.pathname === "/health") {
if (isShuttingDown) {
return new Response("Shutting down", { status: 503 });
}
return new Response("ok", { status: 200 });
}
// ... route handling
}
Environment variable management in Bun has a useful convenience: it automatically reads .env files without requiring the dotenv package. In production, you can override this behavior by setting variables through your deployment platform, which takes precedence over the .env file.
Monitoring and Observability
Production observability with Bun requires the same instrumentation you would use with Node.js, with minor adjustments for the runtime differences. Structured logging, health checks, and metrics collection all work through standard npm packages.
// Structured logging with pino (works with Bun)
import pino from "pino";
const logger = pino({
level: process.env.LOG_LEVEL || "info",
transport:
process.env.NODE_ENV !== "production"
? { target: "pino-pretty" }
: undefined,
serializers: {
req: (req) => ({
method: req.method,
url: req.url,
userAgent: req.headers.get("user-agent"),
}),
err: pino.stdSerializers.err,
},
});
// Request logging middleware
function withLogging(handler: (req: Request) => Response | Promise<Response>) {
return async (req: Request): Promise<Response> => {
const start = performance.now();
const requestId = crypto.randomUUID();
const childLogger = logger.child({ requestId });
childLogger.info({ req }, "Request received");
try {
const response = await handler(req);
const duration = Math.round(performance.now() - start);
childLogger.info(
{ status: response.status, duration },
"Request completed"
);
return new Response(response.body, {
status: response.status,
headers: {
...Object.fromEntries(response.headers),
"x-request-id": requestId,
"x-response-time": `${duration}ms`,
},
});
} catch (error) {
childLogger.error({ err: error }, "Request failed");
return new Response("Internal Server Error", { status: 500 });
}
};
}
For metrics, Prometheus client libraries work under Bun. Expose a /metrics endpoint that reports request counts, latency histograms, and active connection gauges. If your infrastructure uses OpenTelemetry, the JavaScript SDK is compatible with Bun, though some auto-instrumentation plugins that hook into Node.js internals may need manual configuration.
Memory monitoring deserves special attention. Bun's memory profile differs from Node.js because JavaScriptCore uses a different garbage collector strategy. Monitor RSS (Resident Set Size) and heap usage through the standard process.memoryUsage() API, which Bun implements for compatibility. Set memory limits in your container orchestrator to catch leaks early, and use Bun's built-in bun:jsc module for detailed heap profiling during development.
The ecosystem around Bun continues to mature rapidly. For teams building new TypeScript-first applications, starting with Bun eliminates an entire layer of toolchain complexity. For teams migrating existing Node.js applications, the incremental approach, beginning with the package manager and test runner before switching the runtime, provides a safe path to production with measurable performance improvements at each step.