Choosing a JavaScript Toolchain: npm, Yarn, pnpm, and Bun
This site has been through the whole arc. It shipped on npm-era tooling, moved to pnpm, and in March 2026 I switched it to Bun — one commit that deleted a 5,668-line
pnpm-lock.yamland replaced it with an 1,177-linebun.lock. A few months later Prettier gave way to Biome, and Husky to lefthook. What follows is what I would tell myself before starting: which of those moves paid off, which were smaller than advertised, and which decision people keep getting tangled up in.
Versions and claims below are current as of August 2026 — Bun 1.3.x and Node.js 24 LTS. This area moves quickly; check the primary docs before committing to a design.
Two Decisions, Not One
Almost every “should I use Bun?” argument goes wrong in the same place: it treats one question as two, or two questions as one.
- Which package manager installs your dependencies? npm, Yarn, pnpm, or Bun.
- Which runtime executes your code in production? Node.js or Bun.
These are independent. You can run bun install and bun test in development while your production process is Node — that is exactly what this site does. Bun manages the dependencies, runs the scripts, and runs the test suites; the deployed Next.js application still serves on Node, because nothing here opts into anything else.
That last part is worth stating carefully, because bun run is not the same as running on Bun. Invoking bun dev runs the Next.js CLI, but Next itself still executes under Node unless you explicitly pass --bun. Using Bun as your package manager tells you nothing about which runtime serves your requests.
Splitting the two matters because the risk profiles are completely different. Swapping the package manager is a local, reversible change: delete a lockfile, generate another, confirm the build. Swapping the runtime means your production code meets a different JavaScript engine and a different implementation of the Node APIs — a much larger claim to verify.
Adopt the package manager first. Treat the runtime as a separate decision, made later, with evidence.
Quick Overview
- npm: the default. Ships with Node, flat
node_modules, universally supported. No reason to leave it until something specific hurts. - Yarn: two different tools sharing a name. Classic (v1) is in maintenance; Berry (v4) resolves through Plug’n’Play with no
node_modulesat all. Strong zero-install CI story. - pnpm: a content-addressable store plus a strict, symlinked
node_modules. Wins on disk usage and on catching undeclared dependencies. Strongest in monorepos. - Bun: a single binary that is package manager, script runner, test runner, bundler, and runtime at once. Fastest installs, fewest devDependencies, newest ecosystem.
Comparison Matrix
| npm | Yarn (Berry) | pnpm | Bun | |
|---|---|---|---|---|
| Lockfile | package-lock.json | yarn.lock | pnpm-lock.yaml | bun.lock (text, since 1.2) |
| Default resolution | Flat, hoisted node_modules | Plug’n’Play, no node_modules | Symlinked, strict node_modules | Flat, hoisted node_modules |
| Disk across projects | Duplicated per project | Shared cache of zip archives | Shared global store, hard-linked | Shared global cache |
| Undeclared deps | Accessible (silent bugs) | Blocked by default | Blocked by default | Accessible |
| Workspaces / monorepo | Yes | Yes, plus constraints | Yes, the strongest option | Yes |
| Built-in test runner | No | No | No | Yes (bun test) |
| Built-in bundler | No | No | No | Yes (bun build) |
| Runs TypeScript directly | Via Node 24 type stripping | Via Node 24 type stripping | Via Node 24 type stripping | Yes, natively |
| Is also a runtime | No | No | No | Yes |
| Needs Node installed | Yes | Yes | Yes | No |
| Main friction | Phantom dependencies | Tools that assume node_modules | Tools that assume a flat layout | Ecosystem maturity |
Deep Dive
The story that opens this post runs npm to pnpm to Bun, because that is the path this site actually took. Yarn never featured in it — but it is the second most used package manager in the ecosystem, so it gets the same treatment here as the rest.
npm
The baseline, and genuinely fine. It ships with Node, every CI provider caches it, and every tool assumes it works. Its weakness is the flat, hoisted node_modules: a package you never declared can be imported successfully because something else pulled it in. That works until the day a transitive dependency changes its own dependencies and your untouched code breaks.
Leave npm when you have a concrete reason — disk pressure, monorepo workspaces, install times in CI, or phantom-dependency bugs. Not before.
Yarn
Yarn deserves care because “Yarn” names two tools that are not the same product.
Yarn Classic (v1) is the one most people picture: yarn.lock, a flat node_modules, a near drop-in replacement for npm. It is in maintenance mode. If you are on it today you are on a frozen tool, and the interesting question is where you go next — not whether to upgrade in place, because Berry is a different enough animal that it is a migration either way.
Yarn Berry (v4) replaced the whole resolution model with Plug’n’Play. There is no node_modules. Yarn generates a .pnp.cjs file that maps every import directly to a package inside a zip archive in the cache. Nothing is extracted, so installs after the first are close to instantaneous.
That unlocks the feature Yarn has that nobody else does: zero-installs. Commit the cache to the repository and CI needs no install step at all — checkout is the install. For teams where CI install time is the bottleneck, that is a genuinely different answer from “make installs faster.”
Berry also enforces declared dependencies (PnP simply cannot resolve what you did not declare, so phantom dependencies are impossible rather than merely discouraged) and ships constraints, a rule engine for keeping dependency versions consistent across a monorepo — the most powerful workspace-governance feature of any of these tools.
The cost is compatibility, and it is the sharpest in this comparison. Any tool that reads node_modules off the disk — some bundlers, editors, native-module tooling, a long tail of packages doing their own path resolution — needs a PnP-aware integration or it breaks. Yarn provides an escape hatch (nodeLinker: node-modules) that turns PnP off and installs a conventional tree, but running Berry that way gives up most of what makes Berry interesting.
Choose Berry when CI install time genuinely dominates and you can commit the cache, or when you need constraints to govern a large monorepo. Avoid it if your toolchain is full of things that assume a real node_modules on disk.
pnpm
pnpm’s two ideas are worth understanding even if you end up on Bun, because they are the ideas the others are measured against.
A content-addressable store. Packages are stored once in a global store (~/.pnpm-store) and hard-linked into each project. Ten projects on the same version of React cost roughly one copy on disk, not ten. In a monorepo the effect compounds.
A strict node_modules. Only your declared dependencies are reachable from your code; everything else lives in a nested .pnpm directory and is symlinked into place. Undeclared imports fail immediately rather than working by accident. pnpm is likewise stricter about peerDependencies, surfacing mismatches at install time instead of at runtime.
That strictness is the real reason to pick pnpm, and it is also the main source of friction: tools that assume a flat layout — some bundlers, some React Native and Electron setups, some packages with implicit native-module expectations — can need configuration to cooperate.
pnpm remains the strongest choice for large monorepos with many packages and strict dependency boundaries.
Bun
Bun collapses several tools into one binary. The package manager is the least controversial part of it and the easiest to adopt: it reads your existing package.json, supports workspaces, and produces a bun.lock that (since 1.2) is text and reviewable in a pull request.
The consolidation is the real benefit, more than raw speed. On this site, moving to Bun and Biome together removed Prettier, lint-staged, and later Husky from devDependencies, and no test framework was ever added — bun test was already there. Fewer tools means fewer version conflicts, fewer config files, and less to upgrade.
The cost is ecosystem age. Bun is years old where Node is fifteen, and the further you get from mainstream web packages, the more likely you are to be the first person to hit a given bug.
Migrating from pnpm to Bun
The package-manager migration is genuinely small. This is the whole of it:
# 1. Remove the old lockfile and dependency tree
rm pnpm-lock.yaml
rm -rf node_modules
# 2. Install — reads the existing package.json, writes bun.lock
bun install
# 3. Pin the version so CI and every contributor agree
bun pm pkg set packageManager="bun@1.3.11"Then update the places that name the old tool. Script invocations:
# Before
pnpm install
pnpm dev
pnpm dlx prisma generate
# After
bun install
bun dev
bunx prisma generateAnd git hooks — this repo used Husky at the time of the switch, and moved to lefthook later:
# lefthook.yml
pre-commit:
commands:
biome:
run: bunx biome check --write --no-errors-on-unmatched {staged_files}
stage_fixed: trueVerify with a full production build, not just a dev server. Installing is the easy part; the failure mode you care about is a dependency that resolves differently under a flat layout.
What Bun Replaces — and What It Doesn’t
This is where most of the confusion lives, so it is worth being precise about which tools actually leave your package.json.
It replaces your test framework
bun test is a Jest-compatible runner built into the binary — describe, test, expect, mocking, snapshots, watch mode, coverage. It needs no configuration and no dependencies. This site’s three suites run on it with nothing installed:
import { describe, expect, test } from 'bun:test';If you are on Vitest or Jest today, this is the migration with the best return: the assertion API is close enough that most suites move with minimal edits, and you delete a dependency tree in exchange.
Worth knowing that Node closed this gap too. node --test has been built in for a while and is now genuinely production-quality, with assertions, mocking, coverage, and watch mode. “You need a third-party test framework” stopped being true on both runtimes.
It does not replace Prettier or ESLint
Bun ships no linter and no formatter. If you drop Prettier after adopting Bun, something else is doing that job.
On this site that something is Biome — one Rust binary that does both formatting and linting, and the actual reason Prettier and ESLint left the dependency list. Bun and Biome are complementary, not alternatives, and pairing them is the current sensible default. That migration has its own post; the short version is that Biome provides migrate eslint and migrate prettier commands that read your existing config.
TypeScript: the gap closed
Bun’s pitch has long led with native .ts and .tsx execution. In 2026 that is no longer a differentiator.
Bun runs TypeScript directly, with no build step. So does Node 24 — type stripping is stable as of v24.12.0, so node app.ts works with no tsconfig, no ts-node, no tsx, and no loader. Node strips annotations and hands plain JavaScript to V8.
Two caveats apply to both: stripping is not type checking, so you still run tsc --noEmit as a separate gate; and syntax that emits code rather than erasing cleanly — enum, parameter properties, legacy decorators — is not supported by Node’s stripper.
The honest summary: Bun’s TypeScript story is smoother, but “it runs TypeScript” is no longer a reason on its own to leave Node.
Git hooks
Nothing about Bun requires a particular hook manager. Husky works if hooks call bunx instead of npx or pnpm dlx. This site later moved to lefthook, which is a single binary with a YAML config — the same consolidation logic as Biome and Bun, applied to one more tool.
Bun as a Runtime
Everything above concerns development tooling, where the blast radius of being wrong is a broken local install. Running Bun in production is a different decision.
Different engine. Bun is built on JavaScriptCore (Safari’s engine), not V8. Most code will not notice. Anything tuned to V8 specifics will: heap flags such as --max-old-space-size, V8 heap snapshots, and --prof output do not carry over, and garbage-collection behaviour and performance characteristics differ. If you have tuned a Node service using the techniques in Memory Management in Node.js, assume that tuning does not transfer. The event loop concepts do transfer — the implementation underneath does not.
Node API compatibility is high but not complete. Bun implements the node: modules directly and the common surface is well covered. The gaps that remain tend to be in native addons (node-gyp), less-used corners of node:vm, node:cluster, and some low-level networking. Check Bun’s Node.js compatibility page against your actual dependency list rather than reasoning from a headline percentage.
Benchmarks deserve suspicion. Bun wins synthetic HTTP throughput comparisons decisively. Those benchmarks measure a bare HTTP server, and real services are dominated by database round-trips, serialization, and network latency — where the runtime difference compresses substantially. Measure your own workload before treating the runtime as a performance fix.
But managed platforms have made the experiment cheap. If you deploy to Vercel, running on Bun is now a supported option rather than a re-platforming project — including for Next.js, which works with it out of the box. It is two changes. First, vercel.json:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"bunVersion": "1.x"
}Then the scripts, which is where the --bun flag from earlier becomes load-bearing — without it, Next.js keeps executing under Node no matter how you invoke it. Vercel requires this for Next.js, and specifically for ISR :
{
"scripts": {
"dev": "bun --bun next dev",
"build": "bun --bun next build",
"start": "bun --bun next start"
}
}Both runtimes run on the same Fluid compute platform and support the same core function features, so this is closer to a toggle than a migration — which also makes it easy to revert if your own numbers disappoint.
The documented gaps are narrow but real: no automatic source maps, no bytecode caching, and no request metrics on node:http or node:https (metrics via fetch work on both). It is a public beta, so weigh that against your risk budget.
The practical position: adopt Bun’s tooling now, and treat the runtime as a separate switch — one you can flip on a platform like Vercel with a config line, but flip on evidence from your own workload rather than someone else’s benchmark.
In CI and Docker
The CI change is mechanical. Bun has an official setup action, and pinning the version matters as much as it did with pnpm:
- uses: actions/checkout@v7
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint & format check
run: bunx biome ci .
- name: Type check
run: bun run typecheck
- name: Test
run: bun test
- name: Audit
run: bun audit --audit-level=high
- name: Build
run: bun run buildThree details worth carrying over:
--frozen-lockfilein CI, always. It fails the run ifbun.lockdisagrees withpackage.jsonrather than silently resolving something new. This is the equivalent ofnpm ci.- No separate cache step. Unlike the pnpm setup, which pairs
pnpm/action-setupwithactions/setup-nodeandcache: 'pnpm',setup-bunhandles caching itself. bun auditexists, so dropping npm does not mean dropping vulnerability scanning. Gate onhighso new high or critical advisories fail the run while moderate findings stay visible without blocking.
In Docker, use the official oven/bun image and keep the dependency layer separate from the source layer so installs stay cached:
FROM oven/bun:1.3.11-alpine
WORKDIR /app
# Dependencies first — this layer is cached until the lockfile changes
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
CMD ["bun", "start"]The layer-ordering rule is the same one that applies to any package manager; see Dockerfile guide and Docker in CI/CD for the general treatment of caching and multi-stage builds.
When to Use Which
| Situation | Use |
|---|---|
| Small project, npm works, nothing hurts | npm — the migration has no payoff yet |
| Large monorepo, many packages, strict boundaries | pnpm — the strict layout and shared store are its home ground |
| Disk pressure across many projects | pnpm |
| Phantom-dependency bugs you keep re-fixing | pnpm — strictness is the point |
| CI install time is your bottleneck | Yarn Berry — zero-installs skip the step entirely |
| Monorepo needing enforced version consistency | Yarn Berry — constraints have no equivalent elsewhere |
| Still on Yarn Classic (v1) | Move — it is in maintenance; Berry, pnpm, and Bun are all migrations from here |
Toolchain full of things that read node_modules | Not PnP — npm, pnpm, or Bun |
| New TypeScript project, no legacy constraints | Bun — fewest tools, fastest setup |
| You want to delete Vitest/Jest config | Bun — bun test is already installed |
Native addons, node-gyp, unusual node: APIs | npm or pnpm on Node — verify compatibility first |
| Mature pipeline, regulated environment, low risk budget | Stay put — the gain is convenience, not capability |
| You need a linter and formatter | Biome — no package manager provides this |
| CPU-bound rendering, already on Vercel | Worth trying the Bun runtime — a config line, and reversible |
You need source maps or node:http request metrics | Node runtime — documented gaps in Bun on Vercel |
The default recommendation for a greenfield project in 2026: Bun for installs, scripts, and tests; Biome for lint and format; and Node as the default production runtime — with Bun a reasonable experiment if your platform makes it a toggle.
That is what this site runs, and the split is deliberate — the tooling decision and the runtime decision were made separately, months apart, on different evidence.