npm error Cannot read properties of null (reading 'edgesOut') looks like a dependency-version problem. It is not. Arborist, npm's dependency-graph engine, is crashing while it walks a graph that no longer matches your lockfile. Every general-purpose assistant will tell you to delete node_modules and clear the cache. For a large share of the people hitting this, that advice is exactly wrong, because their tree is already clean and the broken edge is structural.

Short version: in arborist's model, a package is a node and each dependency is an edge; node.edgesOut is the set of edges leaving a node. The crash means arborist followed a peer-dependency edge to a node it expected to be in the tree and found null instead. Three unrelated conditions produce that same dangling edge, and only one of them is fixed by deleting node_modules. Run npm install --package-lock-only on a clean checkout to tell them apart in seconds.

This crash tends to surface during a mass upgrade, when repos that were quietly unusual all get touched in the same week. What breaks after CI goes green puts it alongside the other npm 12 rollout failures.

What does "Cannot read properties of null (reading 'edgesOut')" actually mean?

The npm v7 "Arborist Deep Dive" laid out the data model this crash lives in: every installed package is a node, every declared dependency is an edge, node.edgesOut is the map of outgoing edges, and edge.to is the resolved node that satisfies an edge. Arborist's ideal-tree builder iterates that map constantly, with lines shaped like for (const edge of node.edgesOut.values()), and it guards missing targets with checks like if (!edge.to || !edge.valid).

The crash sits one level above that guard. The code is holding a reference to a node that is itself null, and it reads .edgesOut off that null. So the guard for a missing target never fires, because the thing that is missing is the parent node the edge points back through. That is a dangling edge: an edge that expects a provider node in the tree, aimed at a slot that provider no longer occupies.

Both npm/cli issue #9787 and issue #8261 land in the same function, #loadPeerSet, inside @npmcli/arborist/lib/arborist/build-ideal-tree.js. Peer-set loading walks outward from a freshly placed node to pull in its peers, and its peers' peers. If a provider node gets placed and then later deduped or pruned, an edge can still point at the slot it vacated. The peer walk reaches that slot, finds null, and throws.

Install-time fragility like this showed up again in npm audit's 410 and invalid-json crashes, where npm prints one message for problems with different roots and the message alone will not tell you which root you have.

Why does this crash CI but pass on my machine?

This is a TypeError, a crash inside npm itself, not a resolution error npm reports and hands back to you. Hold on to that distinction, because it explains why the bug is so miserable on a platform team. An ERESOLVE conflict fails predictably and prints which peers disagree, so you fix versions and move on. A null-edgesOut dereference throws a raw stack trace, exits non-zero with nothing actionable, and behaves differently across npm majors, because each major reshapes how arborist builds and prunes the ideal tree.

So the cost lands in CI, not on the laptop. A lockfile that installs fine on your machine can crash npm ci on a runner whose setup-node pulled a different npm major. If your pipeline lets npm float, this becomes a non-deterministic red build that "fixes itself" on re-run, because the cache is now warm and the tree already materialized, then comes back on the next clean runner. Teams burn hours chasing a flaky test that is actually a flaky installer.

That breaks the promise underneath reproducible builds. npm ci is supposed to be deterministic. Determinism holds only while the npm major reading the lockfile also wrote it. I treat the lockfile as a versioned migration rather than an ambient default, which is the argument behind the npm v12 install-scripts lockdown and the --allow-scripts project-install fix. A tree written by one major and read by another is a migration nobody chose to run.

The three broken trees behind one stack trace

The threads all report the same stack trace and then quietly disagree about the cause. Pulling them apart is the actual work here.

Cause 1: a stale tree from a different npm major. A node_modules or package-lock.json written by an older major carries hidden nodes and links the current arborist no longer materializes. OpenHands #834 landed exactly there, and deleting node_modules and reinstalling closed it. Wipe-and-reinstall earns its reputation here, and only here.

Cause 2: a genuinely inconsistent peer graph. A package declares a peer whose provider gets deduped out from under it, leaving a dangling peer edge. Issue #9787 reports it on a Nuxt 3 project running npm 10.8.2 and Node 22.21.0, opened around 20 days ago and still labeled "Cannot Reproduce" and "Needs Triage." Issue #8261 hits the crash against a private registry with complex Nuxt and Vue peer sets. A clean reinstall does nothing here, because the inconsistency is in the manifests, not the cache.

Cause 3: a version bump against an unchanged lockfile. nrwl/nx #30478 crashes on nx@20.6.2 using the lockfile that installed cleanly on 20.6.0. The new package version shifted its peer graph, and the old lockfile pins nodes that no longer line up. Identical symptom, different trigger: the upgrade, not the environment or the cache.

Rendered as a graph, the failure is the same shape in all three, only the reason the provider node vanished differs:

your packagepkg-aprovider nodeexpected in treenullTypeError: Cannot readproperties of nulldepspeerDep edgededuped / pruned#loadPeerSet reads<br/>null.edgesOut

The reframe that matters: the "delete node_modules" reflex only touches cause 1, but two of the three live in your manifests and lockfile, where a wipe cannot reach them. That is why the advice feels random. It works a third of the time and gets repeated as gospel.

Which test tells a stale tree from a broken graph?

Run the graph resolution without touching disk. On a throwaway checkout, delete package-lock.json, then:

npm install --package-lock-only --loglevel=silly

--package-lock-only resolves the ideal tree and writes a lockfile without installing anything into node_modules. If it crashes with a fresh lockfile and no on-disk tree, the problem is not cause 1. You have a real peer-graph inconsistency, cause 2 or cause 3, and no amount of cache clearing will move it.

From there, bisect the dangling edge:

npm ls <suspected-peer-owner>
npm explain <pkg>

npm ls shows you where the peer owner sits in the tree and whether its provider is present; npm explain walks the reason a given package is in the tree at all. You are hunting the node whose provider was deduped away. If instead the crash only happens against your existing on-disk tree and disappears after a full clean, it was cause 1, and the wipe was the right call.

What to do when npm throws edgesOut

Work these in order, because they are ordered by what bites first and what costs least to check.

Separate the symptom from the cause

  1. Read the message literally. If it says ERESOLVE, fix versions and stop here. If it says Cannot read properties of null (reading 'edgesOut'), you have a broken graph, so do not jump to --force. --force papers over a resolution conflict; it does not repair a null node.
  2. Confirm your exact npm major. Run npm -v on the machine that crashes and the machine that does not. Nothing else explains more "works on my machine" reports of this bug than a mismatch there.

Run the test, then hunt the edge

  1. Run the separating test. npm install --package-lock-only on a clean checkout with a freshly deleted lockfile. Crash on a clean tree means cause 2 or 3. Clean result means a wipe will help.
  2. If the clean tree still crashes, hunt the edge. Use npm ls <peer-owner> and npm explain <pkg> to find the node whose provider vanished, then pin or overrides that provider so it stays materialized. For a version-bump crash like nx #30478, regenerate the lockfile against the new package version rather than reusing the old one.

Reinstall last, and pin the major

  1. Only now, if it is a stale tree, delete node_modules and package-lock.json and reinstall under the pinned npm major.
  2. Pin the npm major in CI, not just Node. Set packageManager in package.json via Corepack, or pin npm explicitly in your setup-node step, so the lockfile gets read by the major that wrote it. Regenerate package-lock.json deliberately when you bump npm majors and commit it in the same PR. Skip that and the non-deterministic red build comes back on the next clean runner.

If you are trying to move the npm issue along

Maintainers chasing #9787 off "Needs Triage" today should attach the output of npm install --package-lock-only --loglevel=silly plus the offending package's peerDependencies block. The triage stall is that reporters keep sending "deleting node_modules didn't work" without the graph state that proves it is a peer-set inconsistency. Post that artifact and the issue starts moving again.

Treating lockfile hygiene as part of your supply-chain posture, the way dependency cooldowns treat install timing, keeps this from being a fire drill every time a transitive peer shifts. If you would rather hand the npm-major migration to someone who has already untangled a few of these, get in touch.

FAQ

Does deleting node_modules fix the edgesOut error? Only for cause 1, a tree written by an older npm major. For an inconsistent peer graph or a version bump against an unchanged lockfile, a clean reinstall changes nothing, because the problem is in the manifests.

Is this the same as ERESOLVE? No. ERESOLVE is a resolution conflict npm reports and recovers from, naming the disagreeing peers. This is a raw TypeError crash inside arborist that exits non-zero with no actionable message.

How do I tell which cause I have? Run npm install --package-lock-only on a throwaway checkout with a deleted lockfile. Still crashing on a clean tree means a peer-graph inconsistency; clearing after a wipe means a stale tree.

Why does it crash CI but not locally? A lockfile is bound to the npm major that wrote it. If setup-node pulls a different major, arborist reads the tree differently and dereferences a null node.

Will --force fix it? No. --force overrides resolution conflicts, not a null-node crash. It can even mask the real inconsistency and produce a tree that breaks later.