Start now →

Your node_modules Has 1500 Packages. You Only Asked For 10

By Akshat Tiwari · Published June 8, 2026 · 9 min read · Source: Level Up Coding
Ethereum
Your node_modules Has 1500 Packages. You Only Asked For 10

The Hidden Dependency Tree

Ever Updated a Dependency That Wasn’t Even in Your Project? Might sound wierd. But if you’ve ever added something to overrides in your package.json and thought — wait, I never installed this — you're not alone. And once you understand why this happens, a whole lot of things about npm start making a lot more sense. Let’s pull on this thread.

Every Dependency Has Its Own package.json

Here’s the thing. When you publish a package to npm, you’re essentially publishing a folder — and at the heart of that folder is a package.json. That file lists everything that package needs to run.

So when you write:

npm install express

You’re not just pulling in express. You’re pulling in everything express needs. And everything those things need. And so on, recursively, until the entire tree is resolved.

Go look at express’s own package.json on GitHub — https://github.com/expressjs/express/blob/master/package.json. You'll see things like path-to-regexp, qs, body-parser, debug, and more. You never asked for any of those. But they showed up in your node_modules anyway.

These are called transitive dependencies — packages you depend on indirectly, through the packages you actually installed. Your package.json captures your direct dependencies. But the full picture is the entire tree below it.

Run this in any project:

npm list

Prepare to be slightly horrified at how deep that tree goes. If you want to zoom in on a specific package:

npm list express

This shows you exactly where express sits in your tree, and what pulled it in. It’s one of those commands you run once and suddenly understand why node_modules is the way it is.

This is why a package like react-scripts — which looks like a single dependency — can quietly bring 1500+ packages into your project. Your node_modules folder being 400MB for a "simple" app suddenly makes a lot more sense.

The mental model is straightforward:

your package.json
→ your direct dependencies
→ each has their own package.json
→ their dependencies
→ and so on...

node_modules is just the flattened result of resolving that entire tree. You own the root. npm owns everything below it.

So What Happens When One of Those Deep Dependencies Has a Problem?

Let’s say express uses a package called dep at version 1.1.1. You never installed dep yourself — it just came along for the ride as a transitive dependency.

Now someone discovers a security vulnerability in [email protected]. A CVE gets filed. npm audit starts yelling at you. The maintainers of dep move fast and release a fix — version 1.2.1 is out.

But here’s the problem. The express team hasn’t updated their package.json yet. They're still pointing to 1.1.1. So even though the fix exists, you can't get to it through normal dependency resolution. npm looks at express's package.json, sees [email protected], fetches that version, and moves on. The patched version might as well not exist.

Are you stuck? Do you replace express entirely and go hunting for a secure alternative while your production app sits there vulnerable?

No. You make one change in your package.json:

"overrides": {
"dep": "1.2.1"
}

That’s it. npm sees this and says — no matter who asks for dep, no matter what version they asked for, everyone gets 1.2.1. Express is untouched. Your app works. The vulnerability is gone.

What’s happening under the hood is that npm’s overrides field rewrites the resolved version of a package anywhere it appears in the lock file. It doesn't matter that you never installed dep directly. It doesn't matter that express asked for 1.1.1. The override intercepts the resolution process and substitutes the version you specified.

What makes this so elegant is that you’re not fighting express. You’re just going one level deeper and fixing the exact thing that’s actually broken. npm audit will often tell you exactly which override to write — it knows the vulnerability, the fixed version, and that you can't get there through the tree naturally. It's practically handing you the one-liner.

The only housekeeping tip worth following: leave a comment so future-you knows what this is about.

"overrides": {
"dep": "1.2.1" // CVE-XXXX-XXXX, remove once express updates
}

Because six months from now, you will have zero memory of why that’s there. And when express eventually releases an update that naturally pulls in 1.2.1 or higher, your override becomes redundant. Clean it up.

But Wait — What If Two Dependencies Need Different Versions of the Same Package?

This is where things get more interesting.

Your app uses material-ui, react-window, and react-redux. Each of them has React somewhere in their dependency tree. And they might each be pulling in a different version of it.

Normally, npm actually handles this fine. If two packages need different versions of lodash, npm just installs both versions side by side in node_modules. Each package gets its own copy, tucked inside its own folder. No conflict. No drama. They never even see each other.

But React is a special case — and understanding why is what makes this click.

React stores its state, hooks, and component tree in a single global instance. That’s not an accident — it’s a deliberate design decision. The entire reconciliation system, the fiber tree, the hook call order — all of it assumes there’s one React running the show. When two copies of React exist in the same runtime, they don’t share that instance. They’re completely isolated from each other.

So here’s what happens. Your component calls useState. It's talking to React copy A. But the third-party library that's rendering your component? It's talking to React copy B. Copy B has no idea about the hook call that copy A just registered. They're out of sync, and React's rules about hooks — specifically that hook calls must be consistent across renders — get violated.

The result? You get that infamous error:

“Hooks can only be called inside a function component”

And your code looks perfectly fine. Because it is perfectly fine — the problem isn’t in your code, it’s in the tree. Two React instances are colliding at runtime, and the error message gives you no indication of that.

This is why you’ll sometimes see this in a project’s package.json:

"overrides": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}

This isn’t just version management for cleanliness. It’s architectural enforcement — forcing a single physical copy of React across the entire dependency tree. material-ui, react-window, react-redux — they all get the same instance. One React to rule them all.

Scoped Overrides — What If You Need to Be More Surgical?

A global override is a blunt instrument. It says: everyone gets this version, no exceptions. But what if you don’t want that? What if two of your dependencies genuinely need different versions of the same package, and you want to give each of them exactly what they need?

That’s where scoped overrides come in.

Instead of a global override, you can tell npm to apply a version only within a specific package’s subtree:

"overrides": {
"material-ui": {
"lodash": "^4.17.21"
},
"some-other-lib": {
"lodash": "^3.10.0"
}
}

Now you’re being surgical. When material-ui resolves its dependency tree, it gets [email protected]. When some-other-lib resolves its tree, it gets [email protected]. Nobody else is affected. Each package gets exactly the version it needs, scoped to its own subtree.

For stateless utility packages — lodash, date-fns, axios, and most things like them — scoped overrides are a clean solution to version conflicts. Two packages needing different versions? Scope it and move on.

But here’s the thing. Scoped overrides and singleton requirements are in direct conflict.

If you scope React overrides like this:

"overrides": {
"material-ui": {
"react": "^17.0.0"
},
"react-redux": {
"react": "^18.0.0"
}
}

…you’re right back to two physical copies of React at runtime. The scoping worked exactly as intended — each package got its version. But that’s exactly what breaks React. The singleton problem isn’t a resolution problem. It’s a runtime problem. And no amount of clever overrides config fixes a runtime architecture constraint.

So What Do You Actually Do When Two Deps Genuinely Need Different Versions of React?

First — check if the conflict is even real. Run:

npm ls react

Most of the time, libraries specify their peer dependency ranges loosely enough that they’re both compatible with something like ^18.0.0. The "conflict" looks scarier than it is, and it resolves itself without any intervention. Don't reach for overrides until you've confirmed you actually need them.

If the conflict is real, the realistic options are:

Upgrade the outdated dependency. If material-ui is still on React 17 internally, there's a good chance a newer version of material-ui already supports React 18. Check the changelog. Upgrading your direct dependency is almost always the cleanest path.

Force the higher version and test. If upgrading isn’t immediately possible, force ^18.0.0 globally and run your test suite. Semver minor and patch bumps are usually backward compatible. Major version gaps are where you need to be careful and test thoroughly.

Replace one of the conflicting libraries. If you genuinely can’t reconcile them, one of them has to go. That’s not a package.json problem anymore — it's a product decision. Which library is more central to your app? Which one has a compatible alternative?

The uncomfortable truth is that for singleton packages, no override is a real fix. It’s a patch that buys you time. The real fix is getting your dependency tree to a state where the conflict doesn’t exist in the first place.

The Common Thread Across All of This

Look at every scenario:

They all follow the same shape. You know a better version exists. You just can’t get to it through normal resolution because something in the middle is blocking the path. Overrides let you reach past that middle layer and take direct control of what npm resolves.

It’s a pragmatic escape hatch — not a long-term strategy, not something you sprinkle liberally across your package.json. The moment your direct dependencies naturally resolve to the right versions, your overrides become dead weight. Keep them commented, keep them temporary, and clean them up when the underlying issue is fixed.

But understanding why overrides exist is worth more than knowing the syntax. Every time you add one, you’re peeling back a layer of how npm actually works — how it walks the tree, how it resolves versions, how it decides what ends up in node_modules. And that mental model pays off far beyond overrides alone.

Connect with me : Linkedin


Your node_modules Has 1500 Packages. You Only Asked For 10 was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.

This article was originally published on Level Up Coding and is republished here under RSS syndication for informational purposes. All rights and intellectual property remain with the original author. If you are the author and wish to have this article removed, please contact us at [email protected].

NexaPay — Accept Card Payments, Receive Crypto

No KYC · Instant Settlement · Visa, Mastercard, Apple Pay, Google Pay

Get Started →