Skip to content

ADR-0035 Runtime & toolchain

Company standard runtime and toolchain — Bun for the runtime, package manager, workspaces and test runner, with Bun's built-in APIs preferred over packages; Oxlint, Oxfmt and Vite stay for what Bun does not do.

Decision
Bun, built-ins firstShip & run
Status
Accepted
Date
Scope
Company-wide default for all apps and tooling

ADR-0006 chose Node LTS, pnpm and Vite+, and rejected Bun as “not the reference runtime”. Since then Bun 1.4 has shipped, and it now covers several things we install packages for:

  • One binary runs the app, installs dependencies, runs workspace scripts and runs tests
  • Built-in clients: a PostgreSQL client (Bun.SQL), an S3 client (Bun.S3Client), a Redis client, password hashing (Bun.password, argon2id), .env loading, a shell (Bun.$), globbing and hashing
  • Workspaces and catalogs in the root package.json, isolated installs by default for new workspaces, and lifecycle scripts blocked unless a package is listed in trustedDependencies
  • The ecosystem we use runs on it: Bun documents TanStack Start (Nitro’s bun preset) and Drizzle, Drizzle v1 has a bun-sql driver, Better Auth runs on Bun, BullMQ lists Bun, and Testcontainers runs a Bun smoke test in CI
  • An official distroless image, oven/bun:<version>-distroless, with no shell and no package manager

Each package Bun makes unnecessary is one less dependency to update and audit. Bun’s gaps are real too, and they decide where the built-ins stop.

Pros: the reference runtime; OpenTelemetry auto-instrumentation works; Vitest’s real-browser mode; an LTS line. Cons: every driver and client is a package; three tools to install and pin (Node, pnpm, Vite+); Vite+ is young and its CLI is one more thing to learn.

2. Bun as the package manager only, on Node

Section titled “2. Bun as the package manager only, on Node”

Pros: fast installs, keeps Node’s compatibility. Cons: two tools again, and none of the runtime built-ins — most of the gain is lost.

3. Bun for runtime, packages, workspaces and tests

Section titled “3. Bun for runtime, packages, workspaces and tests”

Pros

  • One tool from laptop to production image
  • Built-in Postgres, S3, Redis and password APIs replace driver and SDK packages
  • bun.lock is text and diffable; lifecycle scripts are opt-in per package
  • An official distroless runtime image

Cons

  • Little OpenTelemetry auto-instrumentation. Since 1.4, OTel’s http and fs instrumentations work on Bun for node:http and node:fs. But Start’s server runs on Bun.serve, not node:http; Bun.SQL and Bun’s fetch are not libraries any instrumentation patches; and Bun treats module.register, the ESM loader hook, as a no-op. Native tracing (Bun.otel) is an open pull request (oven-sh/bun#39965). Request, query and fetch spans come from three small wrappers of ours until it lands (ADR-0015)
  • No real-browser test mode. bun test runs components in happy-dom; focus, keyboard and pointer behavior moves to Playwright (ADR-0014)
  • No linter, formatter or type checker: Oxlint, Oxfmt and tsc stay, as plain dev dependencies
  • Not a replacement for Vite: TanStack Start is built on Vite, so vite build stays, run by Bun
  • No LTS line: one release train, so the version is pinned exactly and upgraded deliberately
  • Node-compatibility gaps: node:module hooks, parts of node:async_hooks, some node:worker_threads options. Playwright runs on Bun since 1.4, but Playwright’s own project lists Bun support as not planned

Pros: secure-by-default permissions, built-in tooling. Cons: no documented TanStack Start setup, and npm compatibility is a mode rather than the default.

Bun is the company runtime, package manager, workspace tool and test runner, in local development, in CI and in the production image (ADR-0003). When Bun has a built-in for something, we use it instead of a package — except where the built-in cannot meet another ADR’s requirement; those exceptions are listed under “Where a package stays”.

Concretely:

  • Version: Bun 1.4.x (1.4.4 at time of writing), pinned exactly in the root package.json as "packageManager": "bun@1.4.4". CI installs that version, and the Docker images use the same version, by digest — the runtime image is oven/bun:<version>-distroless, run as a non-root user (ADR-0016). Renovate upgrades the field and the image tags together

  • Packages: bun install; bun.lock is the only lockfile and is committed; CI and Docker run bun ci, which fails when the lockfile is out of date. Never npm, pnpm or yarn

  • Workspaces: workspaces.packages and workspaces.catalog in the root package.json (ADR-0026), with the isolated linker. bun run --filter targets one package

  • Lifecycle scripts stay blocked. trustedDependencies is empty by default; adding a package there is reviewed like a new dependency. Pre-1.0 packages are added with bun add --exact

  • Tasks are package.json scripts, run with bun run:

    Script Runs
    dev bun --bun vite dev
    build bun --bun vite build
    check oxfmt --check . && oxlint && tsc --noEmit
    fix oxfmt . && oxlint --fix
    test bun test (ADR-0014)
  • Built-ins we use instead of packages:

    Need Built-in Replaces
    Postgres client Bun.SQL, through drizzle-orm/bun-sql (ADR-0008) pg
    S3-compatible storage Bun.S3Client (ADR-0030) @aws-sdk/client-s3, presigner
    Password hashing Bun.password, argon2id (ADR-0009) Better Auth’s default scrypt
    Tests, mocks, coverage bun test (ADR-0014) Vitest, @vitest/coverage-v8
    .env files in development automatic loading dotenv
    Scripts, files, globs, hashes Bun.$, Bun.file, Bun.Glob, Bun.hash execa, fs-extra, glob
  • Where a package stays, and why:

    • ioredis — rate-limiter-flexible and @better-auth/redis-storage accept only the clients they list, and Bun’s Redis client is not one of them; one client for Valkey beats two (ADR-0010)
    • Vite — TanStack Start is a Vite plugin (ADR-0002)
    • Oxlint, Oxfmt, TypeScript — Bun has no linter, formatter or type checker
    • Nodemailer and pino — Bun has no SMTP client and no logger (ADR-0029, ADR-0015)
    • The Markdown pipeline — Bun.markdown is marked unstable (ADR-0021)
    • Start’s createCsrfMiddleware() — it checks Sec-Fetch-Site and Origin without tokens; Bun.CSRF issues tokens, a different design (ADR-0013)
    • Playwright — Bun.WebView drives a headless browser, but has no test runner, traces, cross-browser runs or axe integration; Playwright runs on Bun since 1.4 (ADR-0014)
  • Server code imports from bun only inside src/server/ (ADR-0026), so the browser build never sees it

The deciding factors: one tool instead of three, fewer packages to keep current, built-in Postgres and S3 clients, lifecycle scripts blocked by default, and an official distroless image. We accept maintaining our own request, query and fetch spans until Bun’s native tracing lands, component tests in a simulated DOM, and a runtime without an LTS line.

Easier:

  • A laptop, a CI runner and the image need one binary, at one pinned version
  • Fewer dependencies: no Postgres driver, no AWS SDK, no test runner, no dotenv
  • Installs are fast, and no package runs install scripts unless we listed it

Harder:

  • Request, query and fetch spans are our own wrappers until Bun.otel ships (ADR-0015); Valkey, node:http, jobs, AI calls and logs use maintained instrumentation. Revisit when it ships
  • A Node-compatibility gap in a dependency is ours to work around or report
  • Bun moves fast: read the release notes on every upgrade, and run the full test suite before merging it
  • Examples online for our libraries mostly assume Node and pnpm

Given up: Node’s reference-runtime status and maintained OpenTelemetry instrumentation for the HTTP server, the Postgres client and fetch, Vitest’s real-browser mode, and Vite+’s single vp command. Accepted — one runtime with built-in clients is simpler to run and to secure than a runtime plus the packages it lacks.