npm dependency sprawl: the 4 costs on enterprise frontends
Sonatype logged 454,600 new malicious packages in 2025 and 99.8% of Q4 malware came from npm. What dependency sprawl costs, and the five controls we run.
In this piece
npm dependency sprawl is the gap between the packages a frontend team chose and the packages it actually installs: a package.json with 40 entries that resolves to a lockfile of several hundred, most of them pulled in by something else. Nobody approved those. Nobody reads their release notes. They ship anyway.
It bites hardest on frontends that live for years. An internal dashboard. A customer portal. A design system three other teams consume. The application code settles down, the tree underneath it keeps moving, and every install is a set of decisions made on your behalf by maintainers you have never spoken to.
What does dependency sprawl actually cost?
An attack surface nobody picked
2025 ended the argument about whether this risk is theoretical. On 8 September a maintainer was phished through a fake support domain, npmjs.help, and 18 packages shipped malicious versions, among them chalk and debug, with roughly 2.6 billion weekly downloads behind them (Wiz). The payload ran in the browser, waited for window.ethereum, and rewrote the destination of crypto transactions. Hosting platforms spent that day auditing builds; Vercel's response log is a good record of what the scramble looked like.
A week later came Shai-Hulud, the first npm malware that spread on its own. It scanned an infected machine for secrets, then used any npm token it found to publish poisoned versions of every package that token could reach. GitHub removed more than 500 packages (GitHub) and CISA issued an alert on 23 September (CISA).
Sonatype logged 454,600 new malicious packages across public registries during 2025, and 99.8% of the malware it caught in the fourth quarter came from npm alone (Sonatype). A frontend repo is usually the largest npm consumer a company owns, so it carries most of that exposure.
Audit noise that teaches people to skip audits
Endor Labs found that 95% of vulnerable dependencies in a typical project are transitive, and only around 9.5% of the vulnerabilities are exploitable at function level (Endor Labs). That ratio explains what happens to npm audit in a sprawled repo. A team gets 200 advisories, fixes the dozen inside its control, and learns that the number in the terminal is decoration. When a real one lands, the habit of skipping is already in place.
Upgrades blocked by packages you never chose
Two direct dependencies pinning incompatible ranges of the same transitive package is the normal end state of sprawl. A chart library stuck on old React peers. A form library carrying its own fork of a validator. The upgrade is not blocked by your code, it is blocked by a disagreement between maintainers, and you are the one arbitrating it. This is the cost that shows up as a slipped quarter, and its cause is invisible in the manifest.
Install time and shipped weight
Cold installs and CI minutes scale with the tree, not with your source. So does review cost: a one-line change to package.json arrives with a 2,000-line lockfile diff that nobody reads. Weight follows the same path into the browser. A date library that ships every locale when the product sells in three, where Intl.DateTimeFormat formats the same values for free, is one decision paid on every page load. We have written about what that does to a Lighthouse score.
Why the annual audit and prune does not hold
The usual answer is a yearly cleanup. It fails mechanically: pruning acts on the 40 lines you control, sprawl lives in the several hundred you do not. Remove four unused direct dependencies and the tree barely moves. Next Monday a minor bump to a build tool puts thirty packages back, and the review that let it through saw a single changed line.
Pinning everything fails in the other direction. Freeze the tree and the patches stop arriving too, which is how a 2024 advisory survives into a 2026 build.
What works is unglamorous. Make the tree visible, slow it down, and take away the privileges a package gets at install time for free.
Measure the tree, not the manifest
Three numbers, computed in CI, printed on every pull request:
- direct dependencies, from
package.json - total resolved packages, from the lockfile (
npm ls --all --parseable | wc -l) - packages that run a lifecycle script at install time
The third number is the one that predicts incidents, and almost nobody tracks it. A pull request that moves any of the three by more than a few percent owes the reviewer one sentence explaining why.
Put a cooldown on every install
This is the highest-value change available right now and it takes one line of config. Malicious versions are usually caught within hours. The damage lands in the window between publish and takedown, and CI installing on every push walks straight into that window. Every package manager now ships a delay:
min-release-agein npm, measured in days, since npm 11.10.0 (npm config docs, the implementing PR)minimumReleaseAgein pnpm, measured in minutes, on by default at 1440 (one day) in pnpm 11 (pnpm docs)npmMinimalAgeGatein Yarn--minimum-release-agein Bun
We set seven days on applications. The gate applies to transitive resolution as well as direct picks, which is the whole point: the package you never chose is the one that gets compromised. The trade-off is worth stating plainly. A genuine security patch also waits seven days. Keep an override, drop the window to zero for that one install, explain it in the commit, and put the number back in the same pull request.
Turn lifecycle scripts off by default
Both 2025 attacks executed through an install script. On a modern frontend that step exists for native compilation and very little else. Run npm ci --ignore-scripts in CI and keep a short allowlist for the packages that genuinely build something. pnpm states the same policy as onlyBuiltDependencies.
Expect two or three packages to break the first time. Usually they are downloading a prebuilt binary at install. Those are exactly the ones worth reading twice.
Stop holding long-lived publish tokens
If the team publishes anything of its own, a design system, an internal SDK, a CLI, the ground moved in December. npm revoked every classic token on 9 December 2025, and npm login now returns a two-hour session instead of a durable credential (GitHub changelog). From 3 February 2026 granular write tokens carry a 90-day maximum lifetime (GitHub changelog).
Trusted publishing over OIDC removes the stored secret: the workflow proves its own identity and npm issues a credential that lives for that run. A stolen token was the propagation mechanism for Shai-Hulud, so this is the specific hole, not compliance paperwork.
Give the repo a dependency budget
One in, one out, enforced at review. The question on a pull request that adds a package is not whether the package is good. It is what the package replaces. Three checks before it goes in:
- How many entries does it add to the lockfile?
npm i <pkg> --dry-runanswers in seconds. - Does it run an install script?
- What would we write instead, and how long would that take?
If the honest answer to the third is under a day and the package brings forty entries with it, we write the code. If the package is a parser, a date arithmetic library, a crypto primitive, we take the dependency and accept the maintenance. The judgment is about how much of the tree the team can actually read.
Triage by reachability, not by advisory count
Given that 9.5% figure, sorting a vulnerability list by severity alone wastes most of the effort. The cheap version of reachability analysis is npm why <package>: it names the direct dependency that pulled the thing in, which is the only place a fix can come from. From there the question is whether your code path ever reaches the vulnerable function. If it does not, the advisory goes into a written list with a date to recheck, not into this sprint.
What this looks like on a frontend we run
This site runs on Next.js with a CSS-only design system. Removing Tailwind from production took a build-time dependency and its plugin chain out of the tree; that started as a performance decision and turned into a supply-chain one. Server work goes through Server Actions instead of an API client library. The runtime matters too: Bun and Node resolve and cache installs differently, and both now support an age gate.
None of this is a code-quality exercise. A dependency tree grows by default and shrinks only on purpose, so a team that never decided how big it should be has already decided.
Sources
- Wiz: Widespread npm supply chain attack across debug and chalk
- Vercel: Critical npm supply chain attack response, 8 September 2025
- GitHub: Our plan for a more secure npm supply chain
- CISA: Widespread supply chain compromise impacting the npm registry
- Sonatype: State of the Software Supply Chain, open source malware
- Endor Labs: Demystifying transitive dependency vulnerabilities
- npm docs: config reference, min-release-age
- npm/cli: feat, add min-release-age
- pnpm: Mitigating supply chain attacks
- GitHub changelog: npm classic tokens revoked, session-based auth
- GitHub changelog: classic token creation disabled, granular token changes
Frequently asked questions
How many npm dependencies is too many for a frontend?+
No threshold holds across projects, and any number you read is somebody's average. The useful test is readability: can one engineer name every direct dependency and say what it does. Forty direct entries usually passes that test, ninety does not. On the resolved tree watch the trend instead of the absolute number. A lockfile that grew 30% in a quarter while the product did not is the signal worth acting on.
Does a release cooldown mean security patches arrive a week late?+
Yes, and that is the trade you are making. A seven-day gate delays a genuine fix by up to seven days. The exposure it removes is still larger than the one it adds, because published malware is caught within hours while the vulnerabilities behind most advisories have existed for months by the time anyone writes them up. Keep a documented override: set the window to zero for that single install, say why in the commit, and restore it in the same pull request.
Is pnpm safer than npm for this?+
pnpm hands you a stricter set of defaults. Since version 11 it applies a one-day minimum release age without being asked, and it refuses to let a package use something it never declared, so an undeclared transitive import fails instead of working by accident. npm reaches the same place with explicit configuration from 11.10.0 onwards. What matters is which defaults a fresh clone inherits, not the brand of the package manager.
What about Dependabot or Renovate opening updates every day?+
Automated bumps and dependency discipline pull against each other unless the bot is configured for it. Group the pull requests, one per week per manifest, give the bot the same cooldown window the install uses, and require the lockfile delta in the pull request body. A bot that opens thirty pull requests a week gets merged unread, which recreates the exact risk it was added to reduce.
Related services
Studio
Start a project.
We write about what we build. Tell us what you want to build.