A fair benchmark fixes everything except the tool: BookNest's package.json, one machine and network, and caches that start empty. Cold means an empty cache and no lockfile, as on a new laptop; warm means a full cache and a lockfile but no node_modules, as in a CI job that restores its cache. Yarn 11,798 runs twice, with Plug'n'Play and with the node-modules linker.
#!/usr/bin/env bash
# bench.sh - cold and warm installs of BookNest's dependencies with each package manager.
# Every tool gets its own empty cache under $B/cache, so no earlier download is reused.
set -u
export PATH="$HOME/.bun/bin:$PATH" COREPACK_HOME="$HOME/.cache/node/corepack"
B=~/v5-ch1/bench; RUNS=${RUNS:-3}; SRC=~/v5-ch1/booknest/package.json
now() { date +%s%N; }
secs() { awk -v a="$1" -v b="$(now)" 'BEGIN { printf "%.2f", (b - a) / 1e9 }'; }
install() { # $1 = tool, $2 = cold | warm
local C=$B/cache/$1
case "$1:$2" in
npm:cold) npm install --cache "$C" --no-audit --no-fund ;;
npm:warm) npm ci --cache "$C" --no-audit --no-fund ;;
pnpm:cold) XDG_CACHE_HOME="$C/meta" pnpm install --store-dir "$C/store" ;;
pnpm:warm) XDG_CACHE_HOME="$C/meta" pnpm install --frozen-lockfile --store-dir "$C/store" ;;
bun:cold) BUN_INSTALL_CACHE_DIR="$C" bun install ;;
bun:warm) BUN_INSTALL_CACHE_DIR="$C" bun install --frozen-lockfile ;;
yarn*:cold) YARN_GLOBAL_FOLDER="$C" yarn install ;;
yarn*:warm) YARN_GLOBAL_FOLDER="$C" yarn install --immutable ;;
esac > /dev/null 2>&1 || echo "FAILED: $1 $2" >&2
}
for run in $(seq 1 "$RUNS"); do
for tool in npm pnpm bun yarn-pnp yarn-nm; do
P=$B/$tool
rm -rf "$P" "$B/cache/$tool" && mkdir -p "$P" && cp "$SRC" "$P/" && cd "$P"
case $tool in
yarn-pnp) echo 'nodeLinker: pnp' > .yarnrc.yml ;;
yarn-nm) echo 'nodeLinker: node-modules' > .yarnrc.yml ;;
esac
t=$(now); install "$tool" cold; cold=$(secs "$t") # empty cache, no lockfile
rm -rf node_modules .pnp.cjs .pnp.loader.mjs .yarn/install-state.gz
t=$(now); install "$tool" warm; warm=$(secs "$t") # full cache and lockfile
echo "run $run $tool cold ${cold}s warm ${warm}s"
done
doneEach tool gets its own cache through the setting it understands. COREPACK_HOME is pinned so that moving pnpm 69,400 's XDG_CACHE_HOME does not make Corepack 3,816 re-download pnpm itself mid-run, a skew that is easy to miss, and npm 2,036 's audit and funding requests are off because the other tools make no such calls.