DevInsight

A developer's field notes

Tools
0 viewsAbout 5 min read

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.

#패키지매니저#pnpm#npm#yarn#모노레포#유령의존성#hoisting#node_modules#의존성관리#개발도구

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.

View all Tools

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.

Subscribe to RSS