What Actually Happens in node_modules When You Add a Workspace
Every monorepo tutorial has the same three steps: add a workspaces array to your root package.json, run install, done, you now have a monorepo. What none of them tell you is what your package manager just did to your node_modules folder, and that’s the part that actually matters once you have more than two packages and someone adds a dependency in the wrong place.
I built out an internal package monorepo at Quince for shipping shared npm packages across our services, and the bugs that showed up were never “workspaces don’t work.” They were things like a package importing something it never declared as a dependency, working perfectly in CI for months, then breaking the moment a sibling package removed an unrelated dependency. That’s not a flaky tool. That’s the resolution algorithm doing exactly what it’s documented to do — it’s just that nobody reads that part of the documentation.
The npm model: symlink, then hoist
npm’s workspaces doc is honestly pretty thin on mechanics. What it commits to, in plain language, is this: when you list a folder in workspaces, running npm install symlinks that folder straight into the root node_modules. Given
{
"name": "root",
"workspaces": ["packages/ui", "packages/app"]
}
you end up with:
node_modules/
ui -> ../packages/ui
app -> ../packages/app
That symlink is the entire trick behind “just import your workspace package like a normal dependency.” Node’s module resolution walks up looking for node_modules, finds the symlink, and doesn’t care that it points outside the folder. No registry fetch, no copy, just a pointer to your local source.
The part the workspaces doc doesn’t spell out, but that regular npm install docs do, is what happens to ui’s and app’s actual dependencies. Since npm v3, npm hoists dependencies to the highest level it can get away with. If both packages/ui and packages/app depend on lodash@^4, npm installs one copy at the root node_modules/lodash and neither package gets its own nested copy. If ui needs lodash@^4 but app needs lodash@^3, npm can’t satisfy both at the root, so one of them — whichever loses the placement fight — gets its own private copy nested inside node_modules/app/node_modules/lodash, or wherever the conflict actually occurs in the tree.
This is where the phantom dependency shows up. Say packages/ui depends on date-fns, and packages/app doesn’t declare date-fns anywhere in its own package.json. Because date-fns got hoisted to the root node_modules for ui’s sake, it’s also sitting right there on the module resolution path when app does require('date-fns'). It works. Nothing complains. Then six months later someone refactors ui to drop date-fns, the hoisted copy disappears, and app breaks in production with an error that looks like it’s about a completely unrelated change. Nobody added a dependency to app, and nobody removed one from app, but app was never actually entitled to that import in the first place. The flat node_modules just didn’t have a way to say no.
The pnpm model: symlinks all the way down, on purpose
pnpm’s docs are explicit that this is exactly the failure mode it was built to prevent. Instead of a flat, hoisted node_modules, pnpm keeps a single content-addressable store on disk and hard-links every package’s files into a version-specific folder under a .pnpm directory — something like node_modules/.pnpm/date-fns@2.30.0/node_modules/date-fns. Then it builds the actual dependency graph out of symlinks: your project’s root node_modules/date-fns only gets a symlink into .pnpm if date-fns is a direct dependency you declared. Anything ui depends on internally stays reachable from inside ui’s own resolved folder, not from the root.
Applied to the same layout, pnpm workspaces produce something closer to:
node_modules/
.pnpm/
date-fns@2.30.0/node_modules/date-fns
lodash@4.17.21/node_modules/lodash
ui@1.0.0/node_modules/ui -> ../../../packages/ui
app@1.0.0/node_modules/app -> ../../../packages/app
ui -> .pnpm/ui@1.0.0/node_modules/ui
app -> .pnpm/app@1.0.0/node_modules/app
app’s own dependency tree, inside .pnpm, only links to the packages app actually listed. It has no path to date-fns unless it declared it. pnpm’s own docs call this out directly — the goal is that “only packages that are really in the dependencies are accessible,” which is the direct opposite of npm’s default hoist-everything-you-can behavior. Node still resolves through symlinks fine here, because Node’s resolution algorithm ignores the fact that a path is a symlink and just follows it to the real files.
pnpm also has its own workspace linking knob, linkWorkspacePackages, which controls whether a locally present workspace package gets linked in preference to fetching from the registry, and it will only do that if the local version actually satisfies the declared range — it doesn’t just assume “local wins.” If you want zero ambiguity about that, pnpm gives you the workspace: protocol: declare a dependency as "ui": "workspace:^1.0.0" and pnpm will refuse to resolve it to anything except the local workspace package, failing the install outright if no matching local version exists, rather than silently falling back to the registry.
What this actually means day to day
None of this makes pnpm strictly “better.” It means pnpm will surface a missing dependency declaration as an install-time or runtime error immediately, on the machine of whoever wrote the bad import, instead of letting it sit dormant in a flat node_modules until some unrelated change in a sibling package knocks the hoisted copy out from under it. npm’s model is more forgiving in the short term and that forgiveness is exactly the debt that comes due later, usually for someone who wasn’t in the room when the dependency got added in the first place.
If you’re setting up a monorepo for shared internal packages, this is worth deciding on purpose rather than by whichever tool happened to be the default when the repo was created. Read past the “add workspaces to package.json” step. That step was never the hard part.