← All writing
· 6 min read

121,358 Files

What one enormous node_modules directory reveals about dependency cost, security, and operational drag.

In 2016, Facebook published the post announcing Yarn, and buried in it is a number that still hasn't lost its power.

React Native's package.json listed 68 dependencies. After installing, node_modules contained 121,358 files.

Not 121,358 lines. Files. Roughly 1,785 files per declared dependency.

The rest of that post is a catalog of what happens next when you check that directory into version control, which some teams did. A minor Babel version bump produced 800,000-line commits. Merges could eat an entire day. Install order was non-deterministic, which is a fancy way of saying "works on my machine" became a technical property of the toolchain rather than a joke about it.

That was ten years ago, with 68 dependencies. It has not improved.

The multiplier

The interesting thing about dependency counts is that the number you write down has almost nothing to do with the number you get.

A study of 1,146 active JavaScript projects on GitHub found a median of 15 runtime dependencies. Reasonable. Fits in your head.

Then look at what those 15 pull in. An analysis of package weight found that at the third quartile, six direct dependencies produced an average of 28 sub-dependencies and about 2.18 megabytes on disk, with the averages badly skewed by cases running to 300 megabytes.

Andrew Nesbitt frames the structural cause well: installing an average npm package means trusting 79 other packages and 39 maintainers. Not because npm is careless, but because npm resolves version conflicts by keeping both copies rather than forcing a single version the way stricter ecosystems do. His line for it is that npm settles every disagreement with another copy, and each copy makes the tree bigger.

That's the multiplier. You approve 15 packages. You install several hundred. You trust several dozen strangers you've never evaluated, and most of them you couldn't name.

Half of it never runs

This is the part that turns bloat from an annoyance into something stranger.

A dynamic analysis of 92 CommonJS packages, covering 50,661 dependencies with a median test coverage of 96.9%, found that 50.7% of dependencies were never accessed at runtime. Removable entirely, with builds still passing. Broken down: 14.9% of direct dependencies and 51.3% of indirect ones were bloat. 98.9% of packages had at least one. Removing the dead weight cut installed tree size by a median of 44.8%.

Then there's the production picture, which is worse. Research presented at ASE 2022 looked at 100 popular projects with real production builds and found production dependencies were under 1% of installed dependencies. Median of about five production dependencies against roughly a thousand installed. 59% of declared runtime dependencies never appeared in the production bundle at all. Fifty-one of the hundred projects had zero production dependencies, many being libraries, but still.

So the shape of a typical install is: a thousand packages on disk, half of them never touched by any code path, five of them actually shipping.

It's not just too many, it's the wrong ones

The same study of 1,146 projects catalogued what it called dependency smells, and the hit rates are grim. Unused dependency declarations in 79.8% of projects. Missing dependencies, meaning code importing things that aren't declared, in 63.6%. Pinned dependencies in 52.2%. No lockfile at all in 26.5%.

Four out of five projects are carrying dependencies nobody uses. Nearly two out of three are importing something they never declared, which is a build that works by accident.

This isn't people being lazy. It's that package.json has no feedback loop. Nothing tells you a dependency went unused six months ago. Nothing flags that a transitive dep got orphaned when you swapped libraries. The file only grows, because adding is a command and removing requires an investigation.

You are paying for this in CI minutes

Research at FSE 2024 quantified the bill. Across 20,743 dependency-only commits spanning 1,487 npm projects, 55.88% of CI build time triggered by dependency updates came from dependencies that were unused. For the median project, 54.45% of dependency-related CI time is spent rebuilding and retesting because a package nobody's code imports moved a patch version.

Bots drive most of it. Dependabot alone accounts for 74.52% of that waste. Which is a genuinely funny outcome if you squint: dependency hygiene got automated, pointed at graphs full of dependencies nobody uses, and now burns compute keeping the unused parts current.

The security version of the same number

Everything above is a cost story. Same numbers, read differently, are a risk story.

Every package in that tree can run code at install time through lifecycle scripts. Not at runtime, at install. On your laptop and in your CI runner, which holds credentials.

The 2025 and 2026 worms turned that property into a distribution mechanism. Shai-Hulud spread across 500-plus packages by harvesting npm tokens on install, injecting itself into the victim's top packages, and republishing automatically. Microsoft's ChainDrop writeup in August 2026 described the same pattern across 400-plus packages, stealing npm, GitHub, AWS, Kubernetes, and Vault credentials, often shipped as a tarball with no matching git tag or PR. The code in the registry didn't match the code in the repo, and nothing in a normal workflow would tell you.

Now put that next to the bloat numbers. The blast radius isn't your 15 dependencies. It's the several hundred you installed, half of which your code never calls, all of which can execute on install.

And a meaningful share of that surface has one person behind it. Of roughly 13,000 npm packages with over a million monthly downloads, about half have a single maintainer. Across the wider ecosystem, over four million npm projects are maintained by one person, backed by around 900,000 total maintainers, meaning one compromised account often means many packages.

The compliance version

The EU's Cyber Resilience Act now wants a machine-readable SBOM covering your components and their vulnerabilities, plus vulnerability handling, disclosure policy, and update commitments across a support period. Practitioner guides discussing mid-size Node applications talk about SBOMs covering 800 to 2,000 transitive packages.

That graph stopped being a DevOps inconvenience and became a regulated artifact you produce, retain, monitor, and answer for. Half of which, per the research, your application never executes.

The OpenJS Foundation adds a wrinkle that's worth knowing: they argue against generating SBOMs for JS libraries in isolation, because npm's resolver means a library's standalone tree doesn't match its tree inside a real application, and publishing the standalone version pollutes vulnerability data with false positives. So even measuring the thing accurately is a research problem.

What the number actually says

121,358 matters because it's the moment the abstraction became visible. Everyone knew node_modules was big. Nobody had put a number on how big relative to what you asked for.

The culture that produced it wasn't wrong on its own terms. Small composable modules, publish in thirty seconds, never write a utility twice. That's how the ecosystem got large enough to be unavoidable, and the velocity was real.

But the accounting is unusual. In most systems, the thing you install is roughly the thing you use. Here, half the install is dead weight, 99% never reaches production, four out of five projects carry declarations nobody references, and every piece of it can execute code on your build machine.

The ecosystem made the unit of reuse a single function, and the surprise that reusing thousands of functions means installing thousands of packages was never really warranted.

The number was 121,358 with 68 dependencies in 2016. Nobody has published the 2026 version, which is probably for the best.

← Back to all writing