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.yaml and replaced it with an 1,177-line bun.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.


Note

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_modules at 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

npmYarn (Berry)pnpmBun
Lockfilepackage-lock.jsonyarn.lockpnpm-lock.yamlbun.lock (text, since 1.2)
Default resolutionFlat, hoisted node_modulesPlug’n’Play, no node_modulesSymlinked, strict node_modulesFlat, hoisted node_modules
Disk across projectsDuplicated per projectShared cache of zip archivesShared global store, hard-linkedShared global cache
Undeclared depsAccessible (silent bugs)Blocked by defaultBlocked by defaultAccessible
Workspaces / monorepoYesYes, plus constraintsYes, the strongest optionYes
Built-in test runnerNoNoNoYes (bun test)
Built-in bundlerNoNoNoYes (bun build)
Runs TypeScript directlyVia Node 24 type strippingVia Node 24 type strippingVia Node 24 type strippingYes, natively
Is also a runtimeNoNoNoYes
Needs Node installedYesYesYesNo
Main frictionPhantom dependenciesTools that assume node_modulesTools that assume a flat layoutEcosystem 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 generate

And 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: true

Verify 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 build

Three details worth carrying over:

  • --frozen-lockfile in CI, always. It fails the run if bun.lock disagrees with package.json rather than silently resolving something new. This is the equivalent of npm ci.
  • No separate cache step. Unlike the pnpm setup, which pairs pnpm/action-setup with actions/setup-node and cache: 'pnpm', setup-bun handles caching itself.
  • bun audit exists, so dropping npm does not mean dropping vulnerability scanning. Gate on high so 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

SituationUse
Small project, npm works, nothing hurtsnpm — the migration has no payoff yet
Large monorepo, many packages, strict boundariespnpm — the strict layout and shared store are its home ground
Disk pressure across many projectspnpm
Phantom-dependency bugs you keep re-fixingpnpm — strictness is the point
CI install time is your bottleneckYarn Berry — zero-installs skip the step entirely
Monorepo needing enforced version consistencyYarn 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_modulesNot PnP — npm, pnpm, or Bun
New TypeScript project, no legacy constraintsBun — fewest tools, fastest setup
You want to delete Vitest/Jest configBunbun test is already installed
Native addons, node-gyp, unusual node: APIsnpm or pnpm on Node — verify compatibility first
Mature pipeline, regulated environment, low risk budgetStay put — the gain is convenience, not capability
You need a linter and formatterBiome — no package manager provides this
CPU-bound rendering, already on VercelWorth trying the Bun runtime — a config line, and reversible
You need source maps or node:http request metricsNode 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.

References

MIT 2026 © Daniel Guo.