No Isolation in npm Workspaces, And Why That Matters
npm workspaces make monorepo dependency management feel very convenient, but they deliberately share a single node_modules tree across every package. This design has consequences that quietly break reproducibility.
What npm Workspaces Promise
npm workspaces landed in npm 7 with a simple promise: manage multiple packages from a single root package.json. Run npm install once, and npm takes care of deduplicating shared dependencies across your whole repository. No more lerna bootstrap, no manual symlinking, no juggling separate installs per package.
For teams coming from per-package installs or older tools like Lerna and Yarn Classic, workspaces genuinely feel like a relief. And they are, for simpler monorepos. The trouble starts when that installation convenience quietly becomes a correctness problem, and you only find out during a production deploy at 2am.
package.json. This is not a bug in npm. It is a fully intentional consequence of the shared hoisting model. What is not documented well enough is how badly it can bite you.
To understand why, we need a short detour into how Node.js module resolution actually works and how npm's hoisting strategy sits on top of it.
How npm Workspaces Actually Install Dependencies
When you run npm install at the workspace root, npm reads all workspace manifests, builds one unified dependency graph, and resolves a single flat node_modules tree at the root level. Each workspace package is then symlinked into that same root node_modules folder so packages can reference each other by name.
Node.js module resolution walks up the directory tree looking for a node_modules folder containing the requested package. Because the root node_modules is an ancestor of every workspace package, every package can reach every module installed anywhere in the workspace, regardless of what is declared in its own package.json.
"The directory tree is the access control mechanism. And in npm workspaces, the tree gives every package access to everything."
The Hoisting Decision
npm must decide where to physically place each resolved package. It prefers to hoist packages as high as possible, ideally to the root, to enable deduplication. When two workspace packages depend on the same version of lodash, npm installs it once at the root rather than twice inside each package. That is the whole deduplication benefit workspaces are built around.
The side effect is that where a package actually lands on disk is determined by the entire workspace graph, not by any individual package's needs. You cannot look at a single package.json and predict where its dependencies will physically reside after install. The install layout is a global property of the workspace, not a local one. This matters more than most people realise until they have shipped a broken build.
Phantom Dependencies: The Silent Failure Mode
A phantom dependency is any module your code imports successfully at runtime but which is not declared in your package's own package.json. In an npm workspace, phantom dependencies are trivially easy to accumulate and nearly impossible to spot without purpose-built tooling. I have seen teams go months without realising their codebase is full of them.
// packages/api/package.json only lists: express, pg, dotenv // But this import works fine, because @my-org/web lists lodash const _ = require('lodash'); // phantom dependency function groupByStatus(items) { return _.groupBy(items, 'status'); } module.exports = { groupByStatus };
This code works perfectly in development. It works in CI. It works in staging. It only breaks, with a confusing Cannot find module 'lodash' error, when the api package is deployed or published independently, without the workspace context that was silently providing lodash through a sibling package.
Why This Is Hard to Catch
The phantom dependency problem is particularly nasty because the failure mode simply does not appear in the environment where you do development. The workspace is always fully installed locally, so the phantom dependency is always satisfiable. The bug only shows up under conditions developers rarely test: standalone installation, Docker builds installing only one package's deps, or publishing to the npm registry.
Standard linting tools do not catch this by default. ESLint has no idea about the relationship between import statements and package.json declarations unless you configure it specifically for this. TypeScript resolves types from the same hoisted tree, so it also stays silent. The only reliable detection is purpose-built tooling like eslint-plugin-import with workspace-aware config, or Knip, or a deliberate policy of running each package in true isolation before you ship anything.
Tree Per Workspace
Phantom Deps
Access Root Modules
Workspaces vs Solo Install
Version Conflicts and the Hoisting Lottery
The problem is not just about whether a package is present. It is also about which version you actually get. When two workspace packages depend on different versions of the same module, npm cannot hoist both to the root. It hoists one (typically the version satisfying the most dependents) and installs the other locally inside the package that specifically needs it. The package getting the non-hoisted version receives correct isolation, but entirely by accident. The one picking up the hoisted version may be consuming a version it never asked for and would never have asked for in a standalone install.
// packages/api/package.json { "dependencies": { "axios": "^0.27.0" // wants 0.x } } // packages/web/package.json { "dependencies": { "axios": "^1.6.0" // wants 1.x } } // After npm install, npm hoists axios@1.6.0 to root. // api/ gets axios@0.27.2 in its LOCAL node_modules -- correct. // BUT: any transitive dep of api that requires 'axios' with // no version constraint may resolve to 1.6.0 from root. Surprise.
The Reproducibility Problem
Reproducibility means that a given set of source files plus a given lockfile should produce identical runtime behaviour no matter where the install happens. npm workspaces quietly undermine this in a few ways that are not at all obvious from reading the documentation.
Install Order Sensitivity
The hoisting decisions npm makes can depend on the order in which workspace packages are processed, which version ranges are declared, and which versions already exist in your local cache. Two developers with slightly different caches can get different hoisting layouts from the exact same lockfile. That should be impossible in a properly designed package manager, but here we are.
The lockfile records resolved versions but does not fully encode the physical layout of node_modules. Two installs producing the same lockfile can still produce different filesystem structures, because the lockfile format simply does not mandate where each resolved package physically sits on disk.
The "Works on My Machine" Gradient
Because phantom dependencies are satisfiable in any full workspace install, the classic "works on my machine" problem takes on a whole new dimension here. It is not just that someone has a different Node version. It is that their workspace has a richer transitive dependency graph that silently provides packages the deployment environment does not have. The developer's laptop is basically a better-stocked library. Production is the smaller, stricter one with half the shelves missing.
"The lockfile records what npm resolved. It does not record what your code actually depends on. Those are different things, and npm workspaces widen the gap between them."
Docker and the Isolation Cliff
The most common place where workspace isolation failures become visible is containerisation. The typical pattern is to copy only one workspace package and its declared dependencies into a Docker image, install with npm ci, and run. This correctly installs only what is declared, which is exactly when phantom dependencies crash the application in the most confusing way possible.
FROM node:20-alpine WORKDIR /app # Copy ONLY the api package, no workspace siblings COPY packages/api/package.json ./ COPY packages/api/package-lock.json ./ # often doesn't exist standalone RUN npm ci # installs ONLY declared deps, phantom deps vanish COPY packages/api/src ./src CMD ["node", "src/index.js"] # Crashes with: Cannot find module 'lodash'
The fix most teams reach for is copying the entire monorepo into the Docker image and running a full workspace install. That solves the immediate problem but creates a new one: Docker images that are gigabytes larger than necessary, carrying source code and dependencies for every package in the repo, not just the one you are actually deploying.
How Other Tools Handle This
npm workspaces are not the only way to manage monorepo dependencies. Comparing them with tools that do enforce isolation is quite instructive. You can see clearly what is possible and what tradeoffs each approach is consciously making.
| Tool | Isolation Model | Phantom Deps Possible? | Deduplication |
|---|---|---|---|
| npm workspaces | Single hoisted tree at root | YES, by design | Aggressive |
| Yarn Classic (v1) | Single hoisted tree at root | YES, same model | Aggressive |
| Yarn Berry (PnP) | Strict Plug'n'Play, no node_modules | NO, access control enforced | Via content-addressable cache |
| pnpm workspaces | Symlinked store, non-flat layout | NO, by default | Via hard-linked global store |
| Nx (task runner) | Delegates to underlying PM | Depends on PM | Depends on PM |
| Turborepo | Delegates to underlying PM | Depends on PM | Depends on PM |
pnpm's Non-Flat Layout
pnpm is the most useful comparison here. Instead of hoisting packages to a flat root node_modules, pnpm creates a non-flat structure where each package's node_modules contains symlinks only to its declared dependencies. Those symlinks point into a global content-addressable store, achieving deduplication at the storage level without giving up isolation at the package level. Both things at once, which is exactly what you want.
The practical result is that pnpm will throw a Cannot find module error in development, immediately and loudly, when you try to import a package that is not declared. That is the correct behaviour. npm workspaces let the import succeed silently and push the failure all the way to deployment. I know which one I prefer to deal with.
Yarn Berry's Plug'n'Play
Yarn Berry takes an even more radical approach: it eliminates node_modules altogether. It uses a Plug'n'Play resolver, a generated .pnp.cjs file that maps every import to its exact physical location in a zip-compressed package cache. Access control is enforced at the resolver level. If a package is not declared as a dependency, the resolver refuses the import with a hard error, even inside a workspace. The tradeoff is ecosystem compatibility. Some tools and native modules have not yet been updated to support PnP resolution, which creates real friction that hoisting-based approaches avoid.
Real-World Failure Patterns
The abstract problem gets very concrete very quickly when you actually hit it in a production codebase. These are the specific patterns that teams on npm workspaces run into, often more than once.
The Transitive Dep That Graduates to Direct Use
A team uses date-fns explicitly in their web package. Over time, developers in the api package discover they can import date utilities from date-fns too. It works in tests, it works in staging, nobody questions it. Nobody adds date-fns to api/package.json either, because why bother when it just works. Six months later, the web package migrates to the Temporal API and removes date-fns. The api package, which never declared the dependency, crashes in the next deployment. Nobody understands why at first, because the last change to api's code was weeks ago and all tests are passing green.
The Version Pin That Only Partially Applies
A security advisory requires pinning semver to ^7.5.2. The team updates the one package in the workspace that explicitly lists semver as a dependency. npm re-hoists that version to the root. Other packages silently consuming semver through the shared tree now get the patched version too. It looks like a success. But the team's mental model is now wrong. They think they have audited and pinned semver usage. Actually, multiple packages have an undeclared runtime dependency on it that will break if those packages are ever deployed on their own.
The CI/CD Environment That Is Slightly Different
CI installs the full workspace. The deployment pipeline installs only the relevant package. These two environments are not equivalent, but the gap between them is completely invisible until something breaks. Teams usually spend a fair amount of debugging time on these failures because the surface symptom, module not found, points towards the deployment configuration. Nobody thinks to look at the dependency declaration as the actual root cause.
If a module-not-found error appears only in a deployment or standalone install but never in local development or CI, and your project uses npm workspaces, the first thing to check is whether the module is declared in the failing package's own package.json. It is almost certainly a phantom dependency being satisfied by a sibling workspace package during full installation.
Run cd packages/your-package && npm install && node src/index.js from a clean directory outside the workspace root. If it fails there but not inside the workspace, you have your answer.
Mitigation Strategies Within npm Workspaces
If moving to pnpm or Yarn Berry is not on the cards in the short term, there are practical steps to reduce the risk surface within an npm workspace. None of them are as good as structural isolation, but they are much better than nothing.
eslint-plugin-import with Strict Rules
The eslint-plugin-import package includes rules that flag imports of modules not listed in the nearest package.json. The import/no-extraneous-dependencies rule is the one you want. With devDependencies set to false and workspace-aware configuration, it catches phantom production dependencies at lint time rather than deployment time, which is exactly where you want to find them.
{
"plugins": ["import"],
"rules": {
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": false,
"optionalDependencies": false,
"peerDependencies": true,
"packageDir": [".", "../../"] // both package and workspace root
}
]
}
}
Knip: Unused and Undeclared Dependency Detection
Knip is a dedicated static analysis tool that scans your entire codebase to find unused exports, unused dependencies, and, critically, dependencies that are used in code but not declared in the relevant package.json. It understands workspace structures and can produce per-package reports. Running Knip as a required check in CI is honestly one of the most effective guardrails you can add if you are sticking with npm workspaces.
Isolated Install Smoke Tests in CI
Add a CI job that installs and runs each deployable package in isolation, completely outside the workspace context. This directly replicates what happens in your deployment environment and catches phantom dependency failures before they reach production. The job is not complicated to write, and it catches problems that no static analysis tool can reliably detect on its own.
jobs: isolated-install-check: runs-on: ubuntu-latest strategy: matrix: package: [api, worker] # deployable packages only steps: - uses: actions/checkout@v4 - name: Copy package to temp dir run: cp -r packages/${{ matrix.package }} /tmp/pkg - name: Install in isolation run: cd /tmp/pkg && npm install - name: Run startup smoke test run: cd /tmp/pkg && node -e "require('.')"
Migrating to pnpm Workspaces
For teams willing to invest in a proper migration, pnpm workspaces offer a structurally sound alternative. The migration from npm workspaces is often less painful than people expect. The workspace protocol is compatible, most scripts transfer directly, and pnpm's shamefully-hoist flag provides an escape hatch for packages that genuinely need the flat layout for compatibility reasons. The flag name is deliberately self-deprecating. It is a migration aid, not a recommended long-term configuration.
- Audit phantom dependencies first. Run Knip across the entire workspace before migrating. It is much easier to fix phantom deps while still in the npm context than to debug them after switching to pnpm's stricter resolution.
- Install pnpm and run
pnpm import. This converts yourpackage-lock.jsonto apnpm-lock.yaml, preserving all the resolved versions you currently have. - Start with
shamefully-hoist=true. This replicates npm's flat layout so existing code keeps working while you fix undeclared deps incrementally. - Remove
shamefully-hoistonce things are clean. Disable hoisting and let pnpm's strict resolution surface any remaining phantom dependencies. Fix each one as it appears. - Update CI and Docker pipelines. Replace
npm ciwithpnpm install --frozen-lockfile. Docker can now reliably install individual packages without dragging the entire workspace along.
When npm Workspaces Are Actually Fine
Not every monorepo is equally exposed to this problem. There are genuine situations where npm workspaces are the right call and the lack of isolation causes no real harm in practice. It is worth being honest about this rather than making the tool sound universally broken.
Packages That Are Never Deployed Independently
If your monorepo has a single deployable application, say a Next.js frontend, plus a collection of shared UI libraries that are only ever consumed by that one application inside the workspace, phantom dependencies are an inconvenience but not a real risk. Nothing is ever installed in isolation. The full workspace install is always the deployment unit. The dependency graph correctness issue exists in theory, but it never actually manifests in a way that hurts you.
Internal Tooling Repositories
Repositories containing scripts, code generators, and internal developer tools where nothing is ever published to npm and everything is always run from the workspace root carry the same reduced risk profile. The isolation problem is fundamentally a publication and deployment problem. In a pure internal tooling repo, those scenarios never come up.
When Strict Lint Rules Close the Gap
Teams with mature linting pipelines, mandatory import/no-extraneous-dependencies enforcement, and Knip running in CI can close a large part of the gap between npm workspaces and a truly isolated tool. It does need ongoing discipline though. The tooling catches new violations quickly, but only if the rules are enforced as required checks before every merge and not as optional warnings that developers quietly learn to ignore.
The Design Philosophy Gap
The absence of isolation in npm workspaces is not an oversight. It is a deliberate design choice that prioritises deduplication and installation simplicity over correctness guarantees. npm's maintainers are fully aware of the phantom dependency problem. The choice to keep the hoisted flat tree reflects a judgement that most users benefit more from the convenience than they suffer from the correctness issue. That is a reasonable product call to make.
But it does mean the responsibility for correctness falls entirely on the team using the tool. npm workspaces do not enforce any contract that your package.json accurately reflects your actual runtime dependencies. Only deliberate tooling, process discipline, and ideally a package manager with structural isolation can enforce that contract on your behalf.
The deeper issue is that package.json dependency declarations are supposed to be a machine-readable specification of exactly what a package needs. In a correctly isolated system, that specification is authoritative: install exactly what is declared, and the package works. In npm workspaces, that specification is aspirational: declare what you think you need, and the workspace will probably provide the rest anyway. The specification and the runtime drift apart silently, until the day you deploy somewhere that does not have the full workspace behind it. Then you find out all at once.
npm workspaces pnpm workspaces Yarn Berry PnP eslint-plugin-import Knip Turborepo Nx
The core isolation behaviour described here applies to npm 7 through npm 10 and is unlikely to change without a significant design revision, since the hoisting model is fundamental to how npm's dependency resolution algorithm works. Kindly check the official npm and pnpm documentation for the most current guidance on workspace configuration options.
Related essays
- npm · Node.js
Adding "exports" To Your package.json Is A Breaking Change. Stop Shipping It As A Patch.
Six lines of JSON can make every deep import your consumers rely on throw at runtime, including require('pkg/package.json'). How the exports field really resolves, the TypeScript condition-order bug that fails silently, the dual package hazard, and how to roll it out without breaking anyone.
- CI/CD · Build Systems
Your CI Cache Is a Second Package Manager. Treat It Like One.
A CI cache can cut minutes from a build, or quietly make your release process non-reproducible. The difference is whether you treat it as disposable acceleration or hidden infrastructure.
- Frontend Infrastructure · npm
I Stopped Using npm install. Here's the 40-Second Trick That Changed How I Debug Dependencies.
Most engineers reinstall node_modules when debugging dependency issues. There's a faster way that lets you patch, test, and iterate on third-party packages in seconds - without waiting for CI or publishing to a test registry.