npm’s install-strategy=linked Is Getting Better — You Should Try It

npm has had an install-strategy=linked option since v9, but for the longest time it was marked experimental and, frankly, too buggy to use in anything serious. Even basic things like scoped packages didn’t work properly. That changed recently — and I want to tell you how and why.

A quick refresher: what is install-strategy=linked?

By default, npm uses a hoisted install strategy. All your dependencies (and their dependencies) get flattened into a single node_modules/ directory. This is fast and saves disk space through deduplication, but it comes with a well-known downside: phantom dependencies. Your code can require() packages it never declared as dependencies, simply because they happen to be hoisted into node_modules/.

pnpm solves this (among many other things) with its content-addressable store and symlinked node_modules/ structure. npm’s install-strategy=linked takes a similar approach to the isolation part — for projects that are stuck on npm.

With linked, npm creates a flat store directory (.store/) inside node_modules/ and symlinks each package’s dependencies from its own node_modules/ to the store. The result is a strict, isolated node_modules/ layout — if a package doesn’t declare a dependency, it can’t access it.

To try it, just run:

npm install --install-strategy=linked

Or set it in your .npmrc:

install-strategy=linked

How I got involved

Switching Gutenberg to pnpm has been a long-standing wish for many developers. But Gutenberg is a massive monorepo with ~200 workspace packages, and making that switch is far from trivial — it hit roadblocks. So I started looking for alternatives that would keep us on npm while still getting strict dependency isolation.

That’s when I revisited install-strategy=linked. I’d heard about it before but hadn’t properly tested it. So I tried it on Gutenberg.

To no one’s surprise, it was still very buggy. But the idea was sound, and the npm maintainers were receptive to fixes. At Automattic (where I work), we’re encouraged to contribute to open source — so I decided to roll up my sleeves and contribute.

Over the next three weeks, I created around 18 PRs fixing issues across the board. Here’s what was broken and how it got fixed.

The fixes

Scoped packages, aliases, and package identity

The most fundamental issue: scoped packages lost their @scope/ prefix in symlinks. If you had @wordpress/components, it would get symlinked as just node_modules/components. This happened because the name was derived from the folder basename rather than the package name.

Similarly, aliased packages (e.g., "prettier": "npm:[email protected]") were symlinked under the real package name instead of the alias. This broke imports entirely.

Both were fixed by introducing a proper name vs packageName split for proxy nodes. (#8996)

Dependency resolution and --omit flags

Running npm install --omit=dev is supposed to skip devDependencies. With the linked strategy, it silently installed everything anyway — devDependencies and all. The ideal graph builder was unconditionally traversing all edges regardless of omit flags.

This took a couple of rounds to fix completely:

  • The core --omit flag wasn’t being respected during edge traversal (#9066)
  • Root devDependencies shared with workspace prodDependencies still leaked through (#9081)
  • Proxy nodes were missing the top property, causing --omit=dev to crash with a TypeError (#9064)

Workspace hoisting and isolation

Here’s an ironic one: the linked strategy, which is supposed to enforce strict isolation, was hoisting all workspace packages into root node_modules/ unconditionally. Any workspace could import any other workspace without declaring it as a dependency — the very phantom dependency problem we were trying to solve.

Fixed by filtering undeclared workspace dependencies and only creating root-level symlinks for packages actually declared as dependencies. (#9076)

Idempotency — no more full reinstalls

Running npm install a second time should be nearly instant if nothing changed. With the linked strategy, every run triggered a full reinstall. The flat proxy tree structure didn’t match the nested actual tree, so npm thought everything had changed.

This was a multi-faceted issue involving missing version info on proxy links, mismatched resolved URLs, structural differences between flat and nested trees, and incorrect binary paths. The fix introduced a dedicated #buildLinkedActualForDiff() method and cut repeat install times from ~1.5s to ~0.2s. (#9031#9094)

Listing correctness

npm ls was reporting false UNMET DEPENDENCY warnings for intentionally absent undeclared workspaces and store package devDependencies. (#9095)

File dependencies

Relative file: dependencies (e.g., file:./project2) failed with ENOENT because they were incorrectly routed through the store extraction path instead of being symlinked directly. (#9030)

Windows support

On Windows, bin-linking would fail with EPERM because antivirus or search indexer processes transiently locked recently written files. Added retry logic for EPERM/EACCES/EBUSY errors on Windows. (#9028)

Script execution

Postinstall scripts were running twice for every store package — once for the store entry and once for its symlink. For packages like esbuild whose postinstall modifies files in-place, this race condition could corrupt the install. (#9013)

Peer dependency edge cases

When legacy-peer-deps was enabled and a workspace had a peer dependency on another workspace, the resolver returned a Link node instead of its target, creating duplicate workspace proxies and causing EEXIST errors on subsequent installs. (#9051)

Available in both v10 and v11

All of these fixes have been backported to npm v10 as well (#9011#9084#9098). Make sure you’re on npm v11.11.1 or v10.9.6 (or later) to get all the fixes.

Why should you care?

To be clear: this is not a replacement for pnpm. pnpm remains a more mature solution for strict dependency management, with features like content-addressable storage, build script controls, release age delays, and trust policies. I’d love to see npm adopt similar supply chain security features someday — but that’s a separate conversation.

But not every project can make that switch. If:

  • You are stuck on npm — maybe your CI pipeline, deployment tooling, or team workflow is built around it
  • Your community/team isn’t ready for the change — in open source projects, people can be resistant to change, even when it’s for the better
  • You want strict dependency isolation — no more phantom dependencies, packages can only access what they declare
  • You want packages published to npm to be in good shape — with linked strategy, if it works locally, it’ll work when published, because there are no phantom dependencies hiding issues

Then install-strategy=linked gives you at least the isolation part, without changing your package manager.

It’s still experimental — and that’s okay

I want to be honest: this is still experimental. There are likely edge cases that haven’t been discovered yet. Gutenberg hasn’t adopted it yet — it’s still in the exploration phase, though there is a proposal to adopt it. But even just testing it against a monorepo of that scale has been an excellent stress test and has driven many of these fixes. Every project is different though.

The way I see it, the strategy is fundamentally sound — it just needs real-world usage to shake out the remaining issues. And that’s where you come in.

Try it, break it, report it

Here’s what I’d encourage:

  1. Try it on your project: Add install-strategy=linked to your .npmrc and run npm install
  2. Run your tests: See if everything works as expected
  3. Report issues: If something breaks, file a bug on the npm/cli repo. Feel free to tag me (@manzoorwanijk) — I’m actively working on this and happy to investigate and fix issues
  4. Spread the word: If it works for you, let others know. The more people testing it, the faster it gets stable

Testing the linked strategy against a project as large as Gutenberg is helping improve it for everyone. Your project could help too.

Acknowledgements

A big thank you to the npm CLI maintainers — their prompt reviews, thoughtful feedback, and willingness to merge these fixes made this whole effort possible. Special thanks to Michael Garvin, who reviewed and guided almost all of these PRs. It’s not a given that maintainers of a project this widely used will be so responsive to external contributions, and I’m grateful for that.

Thanks also to Automattic for fostering a culture where contributing to open source is not just allowed but actively encouraged. This work wouldn’t have happened without that support.


All the PRs mentioned in this post can be found here.

Tags: , ,