Guide

Bun fundamentals explained

A greenfield API project needs a runtime, a package manager, a TypeScript compiler, a test runner, and a bundler. On a typical Node stack that means five tools in devDependencies, three config files, and CI steps that each add thirty seconds. Bun collapses that toolchain into a single binary written in Zig around Apple’s JavaScriptCore engine: install dependencies, run TypeScript, serve HTTP, execute Jest-style tests, and bundle for production — often an order of magnitude faster than the Node/npm equivalent. This guide covers Bun’s architecture, bun install and workspaces, Bun.serve, Node.js compatibility limits, built-in SQLite and file I/O APIs, a Harbor Supply order API worked example, a runtime decision table, common pitfalls, and a practitioner checklist — alongside our Node.js fundamentals guide, Deno fundamentals guide, and TypeScript fundamentals guide.

What problem Bun solves

Node.js proved JavaScript belongs on the server, but the developer experience around Node accumulated friction: npm install on large monorepos can take minutes; every TypeScript service needs a compile or transpile step; Jest and esbuild add their own configuration dialects. Teams spend sprint time tuning webpack aliases instead of shipping features.

Bun’s thesis is speed as a product feature. The runtime executes JavaScript and TypeScript directly (no separate tsc pass for development), resolves and installs packages with a native linker written in Zig, and exposes batteries-included APIs — HTTP server, WebSocket support, SQLite bindings, file hashing — so small services do not import a dozen micro-libraries. For teams already on Node, Bun advertises drop-in compatibility: most npm packages and Node built-in modules work unchanged, letting you swap node for bun in scripts and measure the delta before committing to a full migration.

When Bun is a good fit

  • Greenfield HTTP APIs where startup latency and install time matter (serverless, edge-adjacent containers, developer laptops).
  • Monorepos with heavy CI — faster bun install and bun test shrink pull-request feedback loops.
  • Internal tools and CLIs that benefit from native TypeScript execution without a build pipeline.
  • Prototyping Node migrations — run existing test suites under Bun to surface compatibility gaps early.

Bun is weaker when you depend on native Node addons compiled against V8-specific ABIs, when you need a decade of production battle scars (Node LTS still wins on compliance questionnaires), or when your platform mandates Deno’s permission sandbox. For security-sensitive multi-tenant services, compare Bun’s full-trust model with Deno’s explicit flags in our Deno guide.

Architecture: JavaScriptCore, Zig, and the event loop

Unlike Node (V8 + libuv) and Deno (V8 + Rust), Bun uses JavaScriptCore — the engine inside Safari. JSC trades some npm-native-addon compatibility for tight integration with Bun’s Zig standard library: syscalls, networking, and filesystem code paths avoid extra FFI hops. Bun implements a Node-compatible event loop (timers, setImmediate, microtasks) so libraries like Express and Fastify behave as they do on Node, though edge cases exist in older callback-heavy code.

TypeScript and JSX are first-class: Bun strips types and transforms JSX at parse time using its own frontend, so bun run server.ts works without ts-node or tsx. For production bundles, bun build tree-shakes ESM and CJS inputs into a single file suitable for containers or AWS Lambda-style deployments.

Memory and startup profiles differ from V8. Micro-benchmarks show Bun cold starts and HTTP throughput ahead of Node on many workloads, but your application’s dependency graph dominates real-world numbers — profile before rewriting architecture around theoretical speedups.

Package management: install, lockfiles, and workspaces

bun install reads package.json like npm or Yarn, resolves the dependency graph, and writes a text-based lockfile (bun.lock) optimized for diff-friendly reviews. Key behaviors:

  • Hoisting and deduplication — similar to npm’s flat node_modules, with aggressive caching across projects via a global store.
  • Lifecycle scriptspostinstall runs by default; audit whether native compile steps are trustworthy in CI.
  • Workspaces — monorepo workspaces arrays in the root package.json link local packages without npm link ceremony.
  • Catalogs (Bun 1.1+) — centralize version pins for shared dependencies across workspace packages.
{
  "name": "harbor-supply-api",
  "type": "module",
  "scripts": {
    "dev": "bun --watch src/server.ts",
    "start": "bun src/server.ts",
    "test": "bun test",
    "build": "bun build src/server.ts --outdir dist --target bun"
  },
  "dependencies": {
    "hono": "^4.6.0",
    "zod": "^3.23.8"
  }
}

Commit bun.lock to version control. In CI, pin the Bun version (oven-sh/setup-bun@v2 with an explicit bun-version) so lockfile format changes do not surprise deploy pipelines. For mixed teams still on npm, Bun can consume package-lock.json, but standardize on one package manager per repo to avoid drift.

HTTP servers, routing, and built-in APIs

Bun.serve() exposes a high-performance HTTP and HTTPS server on web-standard Request/Response objects:

import { Hono } from "hono";

const app = new Hono();
app.get("/health", (c) => c.json({ ok: true }));

export default {
  port: 3000,
  fetch: app.fetch,
};

// Development: bun --watch src/server.ts
// Bun auto-starts the default export when it exposes port + fetch

Frameworks like Hono (lightweight, runs everywhere), Elysia (Bun-native with end-to-end type inference), and Express (via Node compatibility) layer routing on top. Bun also ships:

  • bun:sqlite — synchronous and async SQLite without a native addon compile step.
  • Bun.file() / Bun.write() — optimized file reads and streaming responses.
  • Bun.password — Argon2 and bcrypt hashing built in.
  • Bun.redis (experimental) — Redis client without ioredis when your stack allows it.

For WebSocket-heavy apps, Bun’s native server supports upgrade handling; compare with our WebSockets and SSE guide for protocol semantics that stay identical across runtimes.

Testing, bundling, and Node compatibility

bun test implements a Jest-compatible API (describe, it, expect, mocks, snapshots) with faster collection and execution. Point it at existing test files — many suites run unmodified. Coverage reporting integrates via bun test --coverage.

bun build bundles TypeScript, JSX, and CSS for browsers or the Bun runtime target. Use it for CLI tools and single-file server artifacts; for complex front-end apps, Vite or Next.js may still be the better fit (see our Next.js guide).

Node compatibility covers most of node:fs, node:crypto, node:stream, and node:http, but gaps remain: some native modules built for Node’s V8 version fail to load; obscure process flags and deprecated APIs may behave differently. Maintain a compatibility matrix in your repo: run bun test and a staging smoke suite before promoting Bun to production entrypoints.

Worked example: Harbor Supply order API

Harbor Supply’s warehouse team needed a small REST service that accepts purchase orders from the storefront, validates line items against inventory, and writes rows to SQLite for nightly ERP export. Requirements: sub-100 ms p95 on a single VPS, TypeScript without a separate compile step, and fast CI on a 40-package monorepo.

Architecture

  • Runtime — Bun 1.2.x behind nginx TLS termination on port 3000.
  • Router — Hono with Zod request validation (zValidator middleware).
  • Storagebun:sqlite with WAL mode for concurrent reads during writes.
  • Auth — API key in Authorization header; keys hashed with Bun.password.
  • Observability — structured JSON logs per request; health endpoint for load balancer probes.

Core handler sketch:

import { Database } from "bun:sqlite";
import { Hono } from "hono";
import { z } from "zod";

const db = new Database("orders.sqlite", { create: true });
db.run(`CREATE TABLE IF NOT EXISTS orders (
  id TEXT PRIMARY KEY, sku TEXT, qty INTEGER, created_at TEXT
)`);

const orderSchema = z.object({
  sku: z.string().min(1),
  qty: z.number().int().positive(),
});

const app = new Hono();
app.post("/orders", async (c) => {
  const body = orderSchema.parse(await c.req.json());
  const id = crypto.randomUUID();
  db.run("INSERT INTO orders VALUES (?, ?, ?, ?)",
    [id, body.sku, body.qty, new Date().toISOString()]);
  return c.json({ id }, 201);
});

CI runs bun install --frozen-lockfile, bun test (unit tests with in-memory SQLite), and bun build for a single deploy artifact. Install time dropped from 94 seconds (npm) to 11 seconds (Bun) on the monorepo root; test suite wall clock fell 40%. They kept Node LTS for the legacy ERP bridge service that depends on a V8-only native module — a clean split documented in the runtime decision table below. Idempotency for duplicate POST retries follows patterns in our idempotency guide.

Runtime decision table

Runtime Best for Trade-offs
Bun Fast installs/tests, native TypeScript APIs, SQLite-heavy microservices, Node migration experiments Younger production history; JavaScriptCore native-addon gaps; no permission sandbox
Node.js LTS Maximum ecosystem compatibility, native addons, enterprise support contracts Slower installs and test runs; TypeScript needs tooling
Deno Security-sensitive services, edge deploys, JSR-first greenfield APIs Smaller package surface; different module conventions
Serverless managed Per-request billing, global anycast, zero server patching Cold starts, CPU/time limits, vendor-specific APIs

Common pitfalls

  • Assuming 100% Node parity — native modules and obscure node: APIs break silently; run integration tests under Bun before switching production entrypoints.
  • Ignoring bun.lock in git — non-reproducible CI installs and surprise dependency drift.
  • Running untrusted postinstall scripts — Bun executes lifecycle scripts by default; use --ignore-scripts in locked-down pipelines when appropriate.
  • Blocking the event loop — synchronous CPU work or huge SQLite transactions still stall concurrent requests; offload heavy compute.
  • Mixing package managers — npm and Bun lockfiles diverge; pick one per repository.
  • Skipping version pins in CI — Bun releases frequently; unpinning breaks builds when lockfile formats change.
  • Using Bun for every service blindly — keep Node for legacy native dependencies until a compatibility path exists.

Practitioner checklist

  • Pin Bun version in CI (oven-sh/setup-bun with explicit semver) and document local install via curl -fsSL https://bun.sh/install | bash or mise.
  • Commit bun.lock; use bun install --frozen-lockfile in deploy pipelines.
  • Run bun test on every pull request; compare results with Node until migration is complete.
  • Prefer ESM ("type": "module") and web-standard fetch for portability.
  • Place services behind a reverse proxy for TLS, rate limiting, and request size caps.
  • Validate request bodies with Zod or similar before database writes; return consistent error shapes.
  • Enable SQLite WAL and periodic backups for embedded databases; do not treat SQLite as a multi-writer cluster store.
  • Log structured JSON with request IDs; handle SIGTERM by draining in-flight HTTP before exit.
  • Re-run native-addon compatibility quarterly as Bun’s Node layer matures.
  • Document which services stay on Node LTS and why — avoids accidental unified migrations.

Key takeaways

  • Bun is an all-in-one JavaScript runtime: package manager, test runner, bundler, and TypeScript executor in one binary.
  • JavaScriptCore and Zig underpin speed-focused I/O; Node compatibility covers most npm workloads but not all native addons.
  • bun install, bun test, and Bun.serve replace multiple devDependencies for small services.
  • Built-in bun:sqlite and password hashing suit embedded-data microservices without compile-time native deps.
  • Adopt Bun where velocity wins; keep Node LTS where compliance, native modules, or Deno-style sandboxing dominate.

Related reading