The cost of dependencies
Contents
Adding a dependency takes one line in a manifest, and someone else’s hard problem is solved. With that line we also take on their bugs, their security holes, their release schedule, and their own dependencies, for as long as our code lives. The cost is small while we write the code and grows once the system is in production and users ask for changes. I have watched teams take whatever was available to keep moving, some on principle, and pay for it in maintenance. So a dependency should be chosen on purpose, after an evaluation. Let’s look at what it costs, and then at how to evaluate one.

Dependencies are easy to love, for three reasons:
- Speed. A package makes the work at hand faster, and that is what a management ladder rewards.
- Depth. It spares us going deep into an area, on the hope that its author did, and did it properly.
- Philosophy. We should stand on the shoulders of giants, and some hold that it is the only acceptable way forward.
All three hold at the start, and again with every new feature. Anyone who will not be around for the maintenance has no reason to weigh it, and I saw it most with borrowed teams and contractors. The costs come later.
The back-of-the-envelope post showed that availability multiplies: a request is only as reliable as the product of everything it touches. That post used the arithmetic to size a system. This one applies it to a dependency tree, looks at the costs the math doesn’t capture, and ends with the checks I run before adding a package.
The reliability math
A system that needs N dependencies works only when all of them work, so its availability is the product of theirs: a × b × c × …. Packages don’t share one failure rate; some are far buggier than others. If we assume a common rate r for simplicity, the product becomes r^N, and the exponent is the problem.
Years ago I measured the dependency tree of an internal service I worked on: about 96 packages in the production tree, and it was that low because we actively cut the ones we didn’t need. Let’s assume each one was 99.999% reliable, a figure real packages rarely reach. The product is still only 0.99999^96 ≈ 99.9%. The chance of a failure is about a hundred times higher than for any single package, and the count alone is responsible for it.
Here is the same calculation for 100 dependencies:
| Per-dependency | Total over 100 |
|---|---|
| 99.999% | 99.9% |
| 99.99% | 99% |
| 99.9% | 90% |
At the bottom row, a hundred dependencies at three nines each, one interaction in ten hits a problem. In practice it means that users find bugs we didn’t write, installs fail from time to time, and upgrades turn into long investigations. A small drop in the per-dependency number gives a large drop in the total. Many good dependencies together can make a mediocre system.
r covers more than crashes. It includes bugs, which are someone else’s defects in code we didn’t write and may not understand; bit rot, when unmaintained code stops working as runtimes, platforms, and other packages change around it; and security, because every dependency can be attacked. All three lower r, and they happen together.
And N is larger than the manifest shows. Installing one package installs its dependencies too, and theirs: tens to hundreds of packages the team didn’t evaluate, from maintainers we don’t know. The bugs and the security exposure cover this whole tree, and the lines in the manifest are a small part of it. A small package can bring a large tree. Some numbers for well-known packages, measured while writing this post; each count is everything a fresh npm install brings, the package included, and the Vite and Next.js rows are app scaffolds with their development tools:
| Package | Packages installed |
|---|---|
@aws-sdk/client-s3 |
26 |
next |
54 |
webpack |
63 |
express |
68 |
eslint |
77 |
@angular/cli |
113 |
| A Vite React app scaffold | 180 |
jest |
316 |
| A Next.js app scaffold | 445 |
| Create React App (deprecated) | 1,289 |
My own repositories are an example. A round of Dependabot security alerts flagged fast-xml-parser, basic-ftp, and elliptic, and none of them is a direct dependency of mine. fast-xml-parser came with the AWS SDK, and the other two are deeper in the tree. I had never chosen them or even heard of them. Dependabot found them because it reads the whole lockfile. I keep my direct dependencies to a minimum, and it didn’t help here, because the security exposure covers the whole tree.
Attacks through popular packages
The classic supply-chain attack was typosquatting: a malicious package with a name one keystroke away from a real one, waiting for a typo. It is easy to avoid: we lose only if we install the wrong package. In the last few years attackers have more often poisoned packages we already use, and delivered the malicious code as a normal update:
- event-stream: an attacker offered to help maintain it, got commit rights, and added code that stole from cryptocurrency wallets to a package with millions of weekly downloads. (Snyk post-mortem)
- The xz-utils backdoor: a contributor spent two years earning trust, became a co-maintainer, and added an SSH backdoor, hidden in binary test files and wired in only in the release tarballs, not in the public git history. It was found by accident.
- In September 2025 the maintainer of
chalk,debug, and sixteen related packages, with billions of weekly downloads between them, was phished through a fake two-factor authentication page. Versions with code that steals cryptocurrency in the browser were available for a couple of hours, and roughly a tenth of cloud environments installed one. (Wiz, Palo Alto Networks) - Days later the Shai-Hulud worm automated the attack. It collects npm tokens from the build environment and publishes itself into every other package the victim maintains. One phished developer led to hundreds of poisoned packages. (Unit 42, Datadog)
It continued this year. axios, with 70 million weekly downloads, shipped two poisoned releases. TanStack’s packages were published by the project’s own release pipeline after attacker code took it over during a run, and a new wave of the worm poisoned hundreds of AntV packages through one maintainer account.
chalk and debug are among the most-downloaded packages on npm, and almost everything pulls them in indirectly. Attackers go after popular packages because they reach the most installs.
The cost of maintenance
Even a perfectly reliable dependency needs work for the life of the project: new versions, deprecations, breaking changes we have to follow whether or not we want the new features, security patches we have to take. If we skip that work, bit rot forces the upgrades on us later, on its schedule instead of ours.
Dependabot makes this work visible. Once a package is in the tree, its new releases arrive as pull requests, and a package under heavy development or active maintenance sends many of them. They churn: a newer release closes the old pull request and opens a new one, and every such action sends an email, so more dependencies mean more of this traffic. Each pull request raises the same questions: why was it updated, and does the change affect us? It may fix a bug that we hit, or a bug in a function we never call. Dependabot opens the pull request either way. On some projects I started every day by updating the dependencies that had new releases since the day before, running the tests, and checking the app by hand. It is a reason to have fewer dependencies: to vendor a simple piece, as described below, or to write it ourselves.
Native-build dependencies are the riskiest: they need a compiler, a platform matrix, and a toolchain that all have to work on every machine and every CI runner, now and at every upgrade. I have had to build such packages myself. My own node-re2 is in the same boat, which is why I built a system that precompiles it for popular platforms.
Abandonware
All of the above assumes someone still maintains the package. In a tree of a hundred-plus transitive dependencies, some have no maintainer. The open-source community calls such packages abandonware. They still install today, but bit rot will catch up with them.
Maintainers leave in two ways. Some announce it and look for a successor: the TRE regular-expression library had a call for a new maintainer in its README until someone took over. We can plan for that.
Others stop answering, and issues and pull requests pile up. A predecessor of mine hit a CSS bug in rollup-plugin-postcss, reported it, sent a fix as a pull request, and got no response. So we shipped his fork, and I later maintained it for a while. Silent abandonment usually leads to forks, and there tend to be several: one fixes a bug, another adds a feature, a third republishes the code under a new name. Every downstream user has to decide which one to use.
There is also a slower trend. Twenty years ago I argued that our field was overwhelmingly young, and that where old programmers go was a question to revisit in fifteen or twenty years. That time has come. The people who have maintained foundational packages since the 2000s are getting older, and a package with a single author, mine included, depends on that one person.
Why not pin everything?
A common reaction to these stories is to pin everything: freeze every version, commit the lockfile, and stop upgrading. It trades one set of risks for another:
- A frozen version can still be unsafe. The pinned release may have a security hole that hasn’t been found yet. The
ellipticalert above came while I was changing nothing: no development, no dependency bump. Freezing would not have prevented it, because the flaw was found after the release. With a freeze-and-forget setup I would still be running the vulnerable version without knowing it. - Unexercised bugs stay. A defect we haven’t hit yet stays in the frozen version.
- We miss later fixes and features. A future release may add exactly what we end up needing. Then we have to do the upgrade later, under pressure, instead of on our own schedule.
- Pinned code ages. A dependency relies on platforms, runtimes, and services that keep changing, and they deprecate things whether or not we upgrade. I have seen pinned CI workflows break when GitHub retired the runner images and action versions they used. The pin only postponed the breakage.
So pinning controls when changes reach us, and it keeps the flaws of the pinned version. A narrower version of the idea works better: don’t be first. The September chalk and debug releases were caught and removed within about two hours, and a mass attack like that is usually caught fast. A patient one like xz is not, and no cooldown helps there. Several package managers now offer a cooldown setting (npm’s min-release-age, pnpm’s minimumReleaseAge), and so does Dependabot: they install a release only after it has been public for a few days. We still get the updates, without being among the first to install them.
How to choose a dependency
None of these costs is a reason to write everything ourselves. The standard library and the runtime’s built-ins grow with every release, so I check them first; Node’s built-ins in particular now cover much of what used to need a package. When a package is still needed, I evaluate it before adding it. Four checks do most of the work.
Who made it?
I prefer a reputable author: a person whose other packages have already worked well for me, a team with a good record like Django’s, or a company I trust.
The router in the first API post is my example. Every major version of that popular router broke our app, because, as I remember it, its authors kept deciding their earlier ideas had been wrong. I replaced it with react-enroute, a small router by TJ Holowaychuk. I already had good experience with his other packages, and he had been one of the most prolific npm authors from the start. It caused no such problems while I was on the project.
We shouldn’t rely on the name, though. A package called react-something is not necessarily made by the React team, and the name says nothing about how well it was built. One app I worked on was forms and tables over a CRUD backend, so tables were on nearly every page. Ours used react-table, picked long ago because of its name: we use React, so we use react-table. Then our top-tier customers reported constant, large slowdowns, which I could not reproduce on my own account or on test accounts. The version we used filtered and paged only in the browser, so it loaded the whole table there. Its server-side mode meant doing both ourselves, which left the component drawing rows, a job for an HTML table. Search and paging were the table’s whole job, and it slowed down both on nearly every page. The customers with the most data, who were also the most valuable ones, were hit hardest, and the fix was a complete rewrite.
How big is its tree?
I measure the whole tree, indirect dependencies included. The simplest way is to add the package to the project and count how many packages came with it. Each of them adds to the exposure described above. Sometimes I accept a large tree, but I usually check the sub-dependencies too, and 200 of them is more than I will read.
I make an exception for packages that are never shipped to users: tools used only in development, which my code doesn’t reference in any way. The exception is for the reliability math only. The security exposure stays, because install scripts run on our machines and in CI, which is where the worm collected its tokens.
How is it maintained?
A long list of open issues means something, and when they accumulated matters. Issues that accumulated over a short time point to a quality problem. Issues that accumulated over years point to a maintenance problem: nobody is working on them. It is worth reading some of them, because many can be small: documentation fixes, minor updates, requests for new features.
Pull requests show more. I look at open and, preferably, closed ones: are they reviewed and discussed, and how long do they wait? The rollup-plugin-postcss fix above never got an answer.
Then the documentation: does it cover what we need the package for, and does the package do what we want? The react-table docs did not make it clear where the filtering happened.
How popular is it?
Popularity helps, with caveats, and it says little about niche packages:
- Downloads. A high count can mean popularity, or one project that is rebuilt constantly for some reason and inflates the numbers.
- Mentions. How many people talk about the package outside its own project, and what they say about it.
- Dependents. How many other packages depend on it. This doesn’t work for packages used mostly by apps, because apps are usually not published to a registry.
Popularity also makes a package a target, as the attacks above show, so it is one check among four.
Vendoring a small piece
When I need only a small piece of a project, and it is simple enough to be unlikely to hide bugs or security problems, I copy it instead of adding the whole package. A small copy is often cheaper over the life of the project than a permanent factor below one:
- A copied file, with a note saying where it came from and under which license. It works like a pinned version.
- A git submodule pinned to a well-known, tested version, an LTS release for example, and a script that copies the needed files into a vendored directory. This is pinning too, but re-pinning is easy: move the submodule to a newer version and run the script. It works only when the source is in a git repository we can reach.
Both carry the trade-offs of pinning from the section above, so I re-pin periodically.
This is the argument of the premature-optimization post from the other side: simpler systems have fewer places where problems can occur, performance bugs there and security and reliability risks here.
What we can’t choose
All of these checks apply to dependencies we can choose, and the ones we can’t choose stay whatever the checks say. I can’t drop, replace, or rewrite the AWS SDK, so I have to keep it and its whole tree. My own care is limited by everyone upstream: I get their dependency practices, good or bad, and have no say in them.
Summary
A dependency is one line to add and a lasting cost to keep. Availability multiplies, so a hundred very reliable packages still add up to a system that is only okay, and the bugs, bit rot, and security exposure cover the whole tree. So we choose dependencies on purpose. Check the built-ins first. Then evaluate each package by its author, the size of its tree, its maintenance, and its popularity. When only a small piece is needed, vendor it and re-pin it periodically.