Backend & APIs No. 079

Bun Runtime skill for Claude Code

Adopt Bun one role at a time, with proof. Claude treats Bun as four separate tools and adopts the low-risk ones first: package manager, then test runner, then bundler, then runtime. It commits the text lockfile, freezes it in CI, audits blocked install scripts, writes Bun.serve handlers with graceful shutdown and raised idle timeouts, and proves Node compatibility with the real suite and a staging image before anything ships.

  • namebun-runtime
  • size223 lines · ≈2,200 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Adopts and operates Bun as a package manager, test runner, bundler, or full runtime - bun install with the text bun.lock and trustedDependencies, Bun.serve routes and websockets, built-in SQL, Redis, S3 and SQLite clients, bun test with mocks and coverage, bun build and single-file executables, Docker images, and finding Node.js compatibility gaps before production. Use when migrating a Node project to Bun, starting a Bun API, replacing Jest with bun test, or debugging code that works in Node but breaks under Bun.

Try asking

  • Switch this Node repo to bun install and bun test without changing production
  • Write a small Bun.serve API with Postgres and graceful shutdown
  • This package works on Node but crashes under Bun. Figure out why

Use it when

  • Migrating a Node project's installs or tests to Bun
  • Starting a new API or CLI on the Bun runtime
  • Building single-file executables with bun build --compile
  • Code behaves differently under Bun than under Node

Skip it when

  • The app targets Deno or its permission model — use deno-runtime
  • You are building a Hono app meant to run on several runtimes — use hono-edge
  • The service stays on Node with Express — use express-node

How to check the result

A clean CI checkout passes bun install --frozen-lockfile, the test suite and tsc, and the pinned production image serves health checks and drains requests on SIGTERM.

The common mistake it prevents

Assuming a package works because bun install succeeded, when its postinstall script was silently blocked because it is not in trustedDependencies.

~/.claude/skills/bun-runtime/SKILL.md

--- name: bun-runtime description: Adopts and operates Bun as a package manager, test runner, bundler, or full runtime - bun install with the text bun.lock and… license: MIT ---

Done well means: the team adopted only the Bun roles that pay off, the lockfile is committed and frozen in CI, lifecycle scripts run only for trusted packages, the test suite passes under the runtime that serves production, and every Node API the app relies on has been exercised under Bun before deploy.

Choose the roles first

Bun is four tools. Adopt them separately and in this order of risk:

RoleRiskAdopt when
Package manager (bun install)LowInstalls are slow; works with Node as the runtime
Test runner (bun test)Low to mediumJest-style suites, no heavy Jest plugins or custom environments
Bundler (bun build)MediumServer bundles, CLIs, single-file executables; keep Vite for complex frontends
Runtime (bun run, Bun.serve)Medium to highDependencies verified under Bun; you control the deploy image

A project can use Bun for installs and tests while production still runs Node. That is a valid end state, not a half-migration.

Workflow

- [ ] 1. Inventory runtime-sensitive dependencies and scripts
- [ ] 2. Switch the package manager, commit bun.lock, freeze it in CI
- [ ] 3. Run the existing test suite under bun test (or keep the old runner)
- [ ] 4. Run the app under Bun locally and in a staging image
- [ ] 5. Replace Node-specific pieces only where Bun has a clear win
- [ ] 6. Build the production image and verify shutdown and health checks

1. Inventory

bun --version
grep -E '"(engines|packageManager)"' package.json
grep -rn "node-gyp\|binding.gyp\|prebuild" node_modules/*/package.json 2>/dev/null | head
grep -rnE "from 'node:(vm|inspector|cluster|v8|worker_threads|http2)'" src | head

Flag: native addons, packages with install scripts, vm/inspector/v8 usage, APM agents that monkey-patch Node internals, and frameworks with documented Bun caveats. Check each against the Node.js compatibility page in the Bun docs for the installed Bun version; compatibility improves every minor release, so verify rather than recall.

2. Package manager

bun install                       # reads package.json, writes bun.lock (text, Bun 1.2+)
bun add zod                       # dependency
bun add -d @types/bun typescript  # dev dependency
bun install --frozen-lockfile     # CI: fail if bun.lock is out of date
bun outdated
bun pm untrusted                  # packages whose install scripts were blocked
bun pm trust <pkg>                # allow one, recorded in trustedDependencies
  • Bun does not run dependency lifecycle scripts unless the package is in trustedDependencies in package.json (a small default allowlist of popular packages aside). A package that “installs fine but crashes at runtime” often had its postinstall blocked; check bun pm untrusted before adding it to the trust list.
  • Projects with the old binary bun.lockb: run bun install --save-text-lockfile once, commit bun.lock, delete bun.lockb.
  • Delete other lockfiles (package-lock.json, pnpm-lock.yaml) in the same commit so tools do not disagree.
  • Workspaces: set install.linker ("isolated" or "hoisted") explicitly in bunfig.toml so behavior does not depend on the Bun version’s default.

3. Tests

import { describe, test, expect, mock, spyOn, beforeEach } from "bun:test";
import { priceWithTax } from "./price";

mock.module("./rates", () => ({ taxRate: () => 0.2 }));

describe("priceWithTax", () => {
  test("adds tax in cents", () => {
    expect(priceWithTax(1000)).toBe(1200);
  });
});
# bunfig.toml
[test]
preload = ["./test/setup.ts"]
coverage = true
coverageThreshold = { lines = 0.8, functions = 0.8 }
bun test                          # discovers *.test.ts, *_test.ts, *.spec.ts
bun test --watch
bun test --coverage --coverage-reporter=lcov
bun test --rerun-each 20 src/flaky.test.ts   # flush out order-dependent tests
bun test -t "priceWithTax"        # filter by name
  • Jest globals (describe, expect, jest.fn) mostly work; suites leaning on Jest environments, custom transformers, or Vitest-only APIs may not. If the suite already runs on Vitest, keep Vitest and only switch installs.
  • DOM tests need a preload that registers happy-dom (@happy-dom/global-registrator).
  • Recent releases add --parallel, --shard and --changed (1.3.13+); confirm with bun test --help before using them in CI.

4. Runtime and Bun.serve

import { sql } from "bun";          // Postgres via DATABASE_URL

const server = Bun.serve({
  port: Number(process.env.PORT ?? 3000),
  idleTimeout: 30,                  // seconds; default 10 closes slow streams/SSE
  routes: {
    "/healthz": new Response("ok"),
    "/api/orders/:id": {
      GET: async (req) => {
        const [order] = await sql`select * from orders where id = ${req.params.id}`;
        return order ? Response.json(order) : new Response("not found", { status: 404 });
      },
    },
    "/api/orders": {
      POST: async (req) => {
        const body = await req.json();
        // validate body (zod/valibot) before use
        return Response.json({ ok: true }, { status: 201 });
      },
    },
  },
  fetch() {
    return new Response("not found", { status: 404 });
  },
  error(err) {
    console.error(err);
    return new Response("internal error", { status: 500 });
  },
});

process.on("SIGTERM", async () => {
  await server.stop();              // stop accepting, let in-flight requests finish
  await sql.close();
  process.exit(0);
});
  • sql tagged templates parameterize values; never build SQL with string concatenation.
  • Websockets: return server.upgrade(req, { data }) from fetch and handle websocket: { open, message, close }; per-connection data goes in ws.data.
  • Built-ins worth using: Bun.file/Bun.write, Bun.password.hash (argon2id by default), bun:sqlite, sql (Postgres; MySQL and SQLite adapters in 1.3+), redis client, s3 client, and Bun.$ for shell scripts.
  • Bun loads .env, .env.local and .env.<NODE_ENV> automatically. In containers, pass real environment variables and do not ship .env files in the image.
  • Bun strips TypeScript types without checking them. Run tsc --noEmit in CI, or bun check on Bun 1.4.3+ if the team adopts it.
  • If you use a Node framework (Express, Fastify, Hono, Elysia), keep its server API instead of rewriting to Bun.serve; Bun implements node:http.
  • bun run <script> executes package.json scripts; CLIs with a #!/usr/bin/env node shebang still run on Node unless invoked as bun --bun <cmd>.

5. Bundling and executables

bun build ./src/index.ts --outdir dist --target bun --minify --sourcemap=linked
bun build ./src/cli.ts --compile --outfile mycli                       # host platform
bun build ./src/cli.ts --compile --target=bun-linux-x64 --outfile mycli-linux
  • --target node when the bundle will run on Node; --target browser for frontend code.
  • Mark native or dynamic-require packages --external <pkg> rather than bundling them.

6. Production image

FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production

FROM oven/bun:1
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER bun
EXPOSE 3000
CMD ["bun", "src/index.ts"]

Pin the image to the exact Bun version used in CI (oven/bun:1.x.y) once the app is in production; minor releases occasionally change behavior.

Gotchas

  • Bun 1.4 (Aug 2026) rewrote the runtime in Rust. Treat upgrades across 1.3 to 1.4 as a runtime upgrade: run the full suite and a staging soak before rolling out.
  • process.memoryUsage() and heap snapshots differ from V8; Node-specific memory tooling does not apply. Use Bun’s documented profiling flags.
  • APM and tracing agents that patch Node internals may silently do nothing; confirm spans arrive before removing the Node deploy.
  • idleTimeout defaults to 10 seconds; long-polling and SSE endpoints die unless you raise it or call server.timeout(req, 0) for that request.
  • Mixing package managers in one repo produces two dependency trees. Pick one.

Verification

  1. bun install --frozen-lockfile succeeds in CI from a clean checkout.
  2. bun test (or the retained runner) passes; tsc --noEmit passes.
  3. The production entrypoint runs under the exact image tag and answers /healthz.
  4. Send SIGTERM during a load test (k6 or oha): no 5xx for in-flight requests.
  5. Compare p95 latency and memory between the Node and Bun builds on the same load; keep Node if Bun shows no measurable gain for the risk.

If a dependency fails only under Bun, reduce it to a single-file reproduction, check the Bun issue tracker, and either pin the working Bun version, replace the dependency, or keep that service on Node.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc