Setting Up a Fair Comparison

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.

bench.sh: cold and warm installs with isolated cachesShell
#!/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
done

Each 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.