6 min read
I timed package-manager-detector 1.9.0 detect against which-pm 4.0.0 on a package-lock fixture on this box: 0.49 ms vs 0.043 ms per call. which-pm returned null on that lockfile-only tree; package-manager-detector reported npm. Gzip for the bundled detect+agent+resolveCommand entry was 2,501 B versus which-pm's 18,635 B.
Short answer: pick package-manager-detector when you need lockfile / packageManager-field detection, agent metadata, and resolveCommand helpers. Pick which-pm when you only care what already installed into node_modules (or bun.lockb) and want a tiny sync-ish check. They answer different questions. No affiliate links in this post.
| Job (median of 9, ms, stopDir = fixture) | pmd detect | which-pm |
|---|---|---|
| package-lock.json present | 0.493 | 0.043 |
| pnpm-lock.yaml present | 0.407 | 0.045 |
| yarn.lock present | 0.466 | 0.041 |
| bun.lock + empty bun.lockb | 0.236 | 0.043 |
| empty package.json only | 0.645 | 0.045 |
| node_modules dir, no lock | 0.652 | 0.044 |
| packageManager field pnpm@9.12.0 | 0.554 | 0.042 |
which-pm was about 11× faster on the package-lock fixture (0.043 vs 0.49 ms) because it only probes node_modules metadata and bun.lockb — it never opens the lockfile. On empty fixtures, detect without stopDir walked parents and took about 1.62 ms here; with stopDir it stayed about 0.65 ms and returned null. Process-agent checks were sub-microsecond: getUserAgent about 0.00045 ms vs whichPMRuns about 0.00081 ms with a set user agent. For spawning the resolved PM next, see tinyexec vs execa; for dead-dependency cleanup after installs, knip vs depcheck.
| Package / entry | Minified | gzip | Input files | Alone on disk |
|---|---|---|---|---|
| package-manager-detector (detect + getUserAgent + resolveCommand) | 8,196 B | 2,501 B | 5 | 84 KB (1 package) |
| pmd detect-only | 2,728 B | 1,114 B | 3 | — |
| which-pm | 56,056 B | 18,635 B | 9 | 1,400 KB (which-pm + load-yaml-file tree) |
| which-pm-runs | 260 B | 200 B | 2 | 28 KB |
package-manager-detector is zero-dependency and about 7× smaller gzip than which-pm's bundled graph here. which-pm-runs is a separate tiny package for the process agent (closest sibling to getUserAgent). Size pressure in CI is the same theme as size-limit vs bundlesize; path joins while walking parents pair with pathe vs node:path.
| Fixture (stopDir = fixture) | package-manager-detector | which-pm |
|---|---|---|
| package-lock.json | { name: npm, agent: npm } | null |
| pnpm-lock.yaml | { name: pnpm, agent: pnpm } | null |
| yarn.lock | { name: yarn, agent: yarn } | null |
| empty package.json | null | null |
| node_modules only | null | { name: npm } |
| packageManager: pnpm@9.12.0 | pnpm agent + version 9.12.0 | null |
| bun.lock + bun.lockb | { name: bun, agent: bun } | { name: bun } |
| npm_config_user_agent=pnpm/... | getUserAgent → pnpm | whichPMRuns → { name: pnpm, version: 9.12.0 } |
| resolveCommand(pnpm, add, [lodash]) | { command: pnpm, args: [add, lodash] } | N/A |
| engines.node | unset | >= 22.13 (ran on 20.19.2) |
The important gap: which-pm inspects installation metadata under node_modules (and bun.lockb), so a fresh repo with only a lockfile returns null until you install. package-manager-detector reads lockfiles and package.json fields and can walk parents unless you set stopDir. Agent lists: 11 agents and 12 lock names in pmd constants on this version. Logging around install scripts is covered in consola vs pino; globbing lockfiles in a monorepo pairs with fast-glob vs globby. For shipping a small detector CLI, tsup vs unbuild and picocolors vs chalk are the usual neighbors.
| Situation | My pick |
|---|---|
| Detect PM from lockfile / packageManager field before install | package-manager-detector |
| Detect what already filled node_modules | which-pm |
| Need resolveCommand / constructCommand for add/run | package-manager-detector |
| Only read npm_config_user_agent | getUserAgent or which-pm-runs |
| Smallest install + gzip for lockfile detect | package-manager-detector |
| Hot path that only checks node_modules | which-pm (about 11× here) |
Bottom line: on this box package-manager-detector 1.9.0 took about 0.49 ms to detect npm from package-lock.json while which-pm 4.0.0 finished in about 0.043 ms but returned null until node_modules existed. Gzip favored pmd at 2.5 KB against which-pm's 18.6 KB. Who should not switch to which-pm: scaffolders and CI that must choose a PM from the lockfile alone. Who should not pull pmd for a post-install check: scripts that only need to know what already landed in node_modules and want the cheaper probe.
How this was made: I installed the packages on the ShopperCove box, built seven fixtures, timed detect (with and without stopDir) vs whichPM over 200 calls × 9 rounds in two processes, compared getUserAgent to whichPMRuns, bundled each entry with esbuild, and recorded install sizes. The JSON results sit next to the scripts. The write-up was drafted with AI help and checked against that output.
Lab evidence
Hands-on on ShopperCove box 6 Oct 2026 ~03:55 IST (8 vCPU Intel Xeon / 15 GB shared Linux, Node 20.19.2). package-manager-detector 1.9.0 vs which-pm 4.0.0 vs which-pm-runs 2.0.0; esbuild 0.28.2. Fixtures: npm/pnpm/yarn/bun locks, empty, nm-only, packageManager field. Median of 9 × 200 (ms, stopDir): pmd 0.493/0.407/0.466/0.236 vs which-pm ~0.043 across locks; empty 0.645 vs 0.045. Behavior: lockfile-only → pmd name+agent, which-pm null; nm-only → which-pm npm, pmd null with stopDir; getUserAgent pnpm vs whichPMRuns {name,version}. Bundle gzip 2,501 vs 18,635 B; install 84 vs 1,400 KB. engines.node >=22.13 on which-pm packages, ran on 20.19.2. Not tested: yarn Berry beyond integrity, Windows, Bun/Deno hosts. No affiliate.
npm 12.2.0 vs pnpm 12.9.1 vs Bun 1.4.2 on one 24-dependency React + Vite app: from scratch 14.2 s / 3.1 s / 1.5 s; lockfile + warm cache 2.9 s / 0.28 s / 0.27 s; lockfile + empty cache 3.9 s / 2.3 s / 0.45 s. Disk, lockfile size and the pnpm phantom-dependency catch.
Summary5 Oct 20267 min read
Hands-on6 Oct 20268 min read
Hands-on6 Oct 20269 min read