Most mysterious install bugs come from how the tree is laid out on disk.
npm's installer (Arborist) works in phases: load the actual tree (what is in node_modules) and the lockfile, compute an ideal tree that satisfies every range, then reify: diff the two and add, remove or move packages on disk.
Node resolves require("x") by walking up node_modules folders, and npm exploits that with hoisting (the default install-strategy=hoisted): each package is placed as high as possible, ideally at the root, so it can be shared. When two packages need incompatible versions, one copy is hoisted and the other is nested under its dependent: node_modules/a/node_modules/lodash.
Consequences:
- Phantom dependencies: your code can import a hoisted package you never declared. It works until a parent stops depending on it. Lint for undeclared imports.
- Duplicates: several versions of the same package can coexist;
npm ls pkgandnpm dedupereveal and reduce them. Duplicated singletons (React, a GraphQL library) cause "multiple instances" errors. - The layout depends on install order and history, which the lockfile pins.
Alternative strategies: nested (no hoisting), shallow (only direct deps at the top), and linked, which stores packages in node_modules/.store and symlinks them, similar to pnpm. pnpm's content-addressable store and strict, symlinked layout save disk space and eliminate phantom dependencies; Yarn PnP drops node_modules entirely.
Example
mkdir layout-demo && cd layout-demo && npm init -y >/dev/null
npm install express@5 --silent
npm ls debug --all
# layout-demo@1.0.0
# └─┬ express@5.3.0
# ├─┬ body-parser@2.3.0
# │ └── debug@4.4.3 deduped
# ├── debug@4.4.3
# ...
ls node_modules | wc -l # ~65 packages hoisted to the top level
node -e "require('debug')" # works: a phantom dependency!
npm install --install-strategy=linked --silent
ls -A node_modules # only express (+ .store, .package-lock.json)
node -e "require('debug')" # Error: Cannot find module 'debug'Remember
- Arborist: actual tree + lockfile -> ideal tree -> reify
- Hoisting shares packages but enables phantom dependencies
- Conflicting versions are nested under their dependents
linkedstrategy and pnpm give strict, symlinked layouts
Watch out for
- Importing a package that only exists because another dependency pulled it in.