Only after deleting node_modules twice did I take another look at package managers
Choosing a package manager is a balancing act between three axes: install speed, disk usage, and phantom dependencies. In a standalone repo, even a careless choice rarely causes problems, but once you move to a monorepo, differences in hoisting strategies show up directly as CI time and disk blowups. This compares how npm, pnpm, and yarn each store and share dependencies, and lays out a decision flow for choosing a tool based on real conditions like team size and monorepo maturity.
Published by DevInsight.
Only after deleting node_modules twice did I take another look at package managers. The first time was because of an unexplained missing dependency, and the second was a storage cleanup script's doing. While deleting the same folder twice, I couldn't answer "why npm?" Hundreds of dependency policies had hardened into "because it's the default."
Choosing a package manager rests on three axes: install speed, disk usage, and phantom dependencies. The three don't move independently. A decision that shrinks one axis bloats another. In a standalone repo, any axis can hurt and still be tolerable. But once multiple packages are bundled into one repo and the team starts to grow, the balance breaks.
Phantom dependencies are dangerous precisely because "it doesn't break right now"
npm and yarn classic share the same strategy: hoisting, pulling dependencies up flat into the root node_modules to cut down on duplicate installs. The side effect is phantom dependencies. Code that requires a package not declared in package.json still runs. Code review can't catch it, builds can't, and tests can't either.
The structure breaks down the moment the dependency graph changes. When a transitive dependency disappears, that access becomes a ReferenceError. When a major update changes a transitive package's API, compatibility breaks apart. Local runs are fine, but CI dies. A new teammate clones the repo and runs install, and it reproduces cleanly. From then on, that code hardens into "our own custom." Finding the cause takes days.
This isn't a matter of personal habit. Hoisting is a structural decision, so no matter how much you fix the code, the same pattern repeats in the next repo. There are two ways to verify: read the dependency tree with npm ls and hunt for mismatches between declarations and actual references, or move to a structure where such access is impossible in the first place. Only the latter is a realistic fix. The former is just a post-mortem of an accident that already happened.
pnpm's symlinks were designed for isolation
pnpm stores each package once in a global store and references it via hard links. node_modules holds only symlinks instead of real files. The more packages a machine pulls, the more disk savings accumulate. When the store is cached, installs get faster too. In CI, passing the store as a cache visibly cuts job time. But the biggest difference you feel isn't these advantages. It's that requiring an undeclared dependency fails immediately.
On the first day of migration, I judged this behavior a bug. Why would code that ran fine on npm die? It wasn't dead — hidden accesses were finally bursting out. During the fixes, half were references that should have pointed at a different package, and half were dependencies genuinely missing from the declarations. The migration itself becomes a phantom dependency audit, taught by the tool you just adopted.
The absence of hoisting shines even more in a monorepo. npm gathers hoisting results at the top level. An accident where package A accidentally requires a dependency of package B crosses boundaries. When an accident happens, you have to trace which declaration is missing, and the cause mostly depends on the hoisting outcome. pnpm exposes only each package's declared dependencies, so that tracing disappears entirely. In a monorepo, differences in hoisting strategy show up directly as numbers: CI time and disk blowups. When each package installs and piles up its own copy of the same transitive dependency, install time adds up and storage fills with duplicates.
pnpm isn't free either. Some tools can't tolerate symlinks: the Electron family, some native modules, and tools that read node_modules directly are the typical ones. In that case, putting node-linker=hoisted in .npmrc reverts to a hoisted structure. The isolation benefit is diluted, but compatibility survives. If there are only a few exceptions, it's a reasonable trade-off from a maintenance standpoint.
yarn shares the name but splits on strategy
yarn classic (1.x) uses the same hoisting as npm, so it inherits the phantom dependency problem wholesale. yarn Berry (2+) uses PnP (Plug'n'Play) as its default mode. Instead of creating node_modules, it records dependency locations in .pnp.cjs. Installs are fast and the repo stays light. But most tools in the world assume node_modules actually exists. PnP frequently trips over native modules, some bundlers, and analysis tools that read node_modules directly. You can switch back with the node-modules linker, but that's giving up half the reason you chose PnP.
If you pick yarn, accept up front that compatibility management is now part of the development schedule. A common pattern is coming in for the advantages and getting only compatibility problems in return. Introducing Berry fresh is something to try when the validation budget is generous.
Team size and repo maturity set the decision flow
Comparing the three tools' structures and picking the tool right for your team are different problems. The fork splits into two.
If it's a standalone repo managed by one person, failures are rare with any tool. Migration cost is the bigger risk. Just staying on npm isn't wrong. As services multiply and teammates grow, isolation and storage efficiency start paying for themselves. The default choice at this point is pnpm.
The second condition is monorepo maturity. Migrating to pnpm from a repo piled up with unorganized dependencies kills a lot of packages. It can get to where a one-day migration looks impossible. In that case, instead of flipping isolation on all at once, it's better to start in the hoisted linker state and tighten things gradually. That doesn't mean abandon the migration — it means spread out the failure modes. The first priority of the migration shouldn't be the install itself, but fixing the undeclared dependencies the migration reveals.
Validation is done with numbers: CI install time before and after migration, size measured with du -sh node_modules, and the count of package.json files touched during the migration. You should also confirm the full test suite produces the same results after migration. If all three numbers improved, the choice was justified.
If node_modules disappears again someday, look at the structure before deleting it. A single line of npm ls --all decides the next choice. There's only one criterion: "Does it force you to use only what you declared?"
Comments
Loading comments.
Good Follow-up Reads
Posts connected to the topic you just read.
ESLint Flat Config 마이그레이션 실패 일지와 살아남는 체크리스트
ESLint 9의 flat config로 넘어가면서 extends가 사라지고, 플러그인 호환성 문제, VS Code ESLint 확장과의 설정 불일치, 글로벌 변수 선언 방식 변화 등 현장에서 마주치는 장애물을 해결 순서대로 정리한다. 삽질을 줄이는 실전 체크리스트. 2025년 4월, ESLint 9가 정식 릴리스되면서 파일은 deprecated 경고를 넘어 아예 무시되기 시작했다.
Decap CMS 유저가 Sveltia로 넘어올 때 가장 많이 묻는 질문
Netlify CMS의 진정한 후계자로 불리는 Sveltia CMS는 Svelte로 처음부터 새로 작성된 Git 기반 헤드리스 CMS다. 번들 300KB, GraphQL 즉시 로딩, 모바일 완벽 지원, 다국어 내장 등으로 기존 Decap CMS의 고질적 문제를 해결하며 2.6k 스타를 기록 중이다.
당신의 마크다운, 지금도 조용히 깨지고 있다
CommonMark, GFM, 그리고 수많은 마크다운 변종 사이에서 같은 파일이 매번 다르게 렌더링되는 이유를 파헤치고, 제로 디펜던시로 처음부터 다시 만든 렌더러가 이 문제를 어떻게 해결하려 하는지 살펴본다.
Previous post
Why the Screen Shatters While Tokens Flow
DevInsight Digest
Keep every new article in one calm feed.
Follow the full publication feed without promotional alerts.