npm error Cannot read properties of null (reading 'edgesOut') looks like a dependency-version problem. It is not. It is arborist, npm's dependency-graph engine, 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.
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 is where arborist 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. This is the same class of install-time fragility I wrote about in fixing npm audit's 410 and invalid-json crashes: npm surfaces one message for problems with different roots, and the message alone does 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. That distinction is the whole reason it is miserable to debug 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 only holds when the npm major reading the lockfile is the one that wrote it. I treat the lockfile the same way I treat any install-time behavior change, 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. This is the OpenHands #834 case, fixed by deleting node_modules and reinstalling. It is the only cause the "wipe and reinstall" advice reliably fixes.
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. This is #9787, a Nuxt 3 project on npm 10.8.2 and Node 22.21.0, opened around 20 days ago and still labeled "Cannot Reproduce" and "Needs Triage." It is also #8261, hitting 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 [email protected] with the same 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. Same symptom, but the trigger is 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:
flowchart LR
A["your package"] -->|deps| B["pkg-a"]
B -->|peerDep edge| C{"provider node<br/>expected in tree"}
C -->|deduped / pruned| N["null"]
N -.->|#loadPeerSet reads<br/>null.edgesOut| X["TypeError: Cannot read<br/>properties of null"]
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.
- Read the message literally. If it says
ERESOLVE, fix versions and stop here. If it saysCannot read properties of null (reading 'edgesOut'), you have a broken graph, so do not jump to--force.--forcepapers over a resolution conflict; it does not repair a null node. - Confirm your exact npm major. Run
npm -von the machine that crashes and the machine that does not. A mismatch here is the single most common "works on my machine" version of this bug. - Run the separating test.
npm install --package-lock-onlyon 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. - If the clean tree still crashes, hunt the edge. Use
npm ls <peer-owner>andnpm explain <pkg>to find the node whose provider vanished, then pin oroverridesthat 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. - Only now, if it is a stale tree, delete
node_modulesandpackage-lock.jsonand reinstall under the pinned npm major. - Pin the npm major in CI, not just Node. Set
packageManagerinpackage.jsonvia Corepack, or pinnpmexplicitly in yoursetup-nodestep, so the major that reads the lockfile is the one that wrote it. Regeneratepackage-lock.jsondeliberately when you bump npm majors and commit it in the same PR. This is the step that stops the non-deterministic red build from coming back.
If you are a maintainer trying to move #9787 off "Needs Triage" today, 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. The reproducible artifact is what moves it. 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.
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.
Comments
Be the first to comment.