Transitive dependencies are the packages your packages depend on: you add a handful of libraries, and each of them brings its own, which bring theirs. Most of that tree is harmless, and the number on its own tells you very little. The real problem is not how many indirect dependencies you have, but that nobody on the team can see them, and nobody chose them.
Why transitive dependencies are a visibility problem, not a count problem
Open package.json in a typical frontend project and you see a short, readable list. Those are your direct dependencies, the ones somebody on the team decided to add. Then open the lockfile, and the list becomes a document you scroll for a long time. That gap is the dependency tree: every package your direct dependencies need, and everything those packages need in turn.
The first reaction is usually alarm, and it is mostly misplaced. The npm ecosystem is built on small packages that do one thing. A build tool, a test runner, and a UI framework will each pull in parsers, path utilities, polyfills, and helpers. A big count of npm dependencies is the design of the ecosystem, not a sign that something went wrong.
What is not normal, and what is worth your attention, is this: none of those indirect dependencies went through a decision. Nobody on the team evaluated the author, the maintenance history, or what the package does when it installs. Each one runs with the same rights as your own code, in your build, on your developers’ machines, and in CI. So the question is not “why do we have so many?” but “which of these would we have refused if anyone had asked?”
That reframing also tells you what dependency bloat really is. It is not a large tree. It is the part of the tree that adds risk or weight without doing anything you need: duplicates, abandoned code, and packages that do more at install time than their job requires.
How the dependency tree is built
To make sense of the tree, it helps to know how the package manager assembles it.
Version resolution
Each package.json declares ranges, not exact versions. ^4.2.0 means “any 4.x from 4.2.0 up.” When you install, the package manager walks the graph, picks a version for every package that satisfies the ranges declared on it, and tries to reuse one copy wherever ranges overlap.
When two parts of the tree need incompatible ranges of the same package, npm does not fail. It installs both copies, nesting the second one under the package that needs it. That is why you sometimes find two or three versions of the same library in one project.
When a conflict cannot be resolved
Nesting does not help when only one copy is allowed. That is the case for peer dependencies: a plugin declares “I work with framework version X” and expects to share the framework instance the app already has, rather than bring its own. A Vue plugin cannot bring a private copy of Vue; it has to use the one the app runs.
Now take two direct dependencies, both reasonable choices on their own. One declares a peer range for the old major version of a library, the other for the new one. There is no version that satisfies both, and there is no way to install two because the whole point is to share one. npm stops with a resolution error. Forcing it with --legacy-peer-deps or --force only hides the conflict; one of the two packages will now run against a version it never claimed to support.
The fix is never in the tree. It is a decision about direct dependencies: upgrade one, replace one, or wait for a maintainer. This is why such conflicts feel unresolvable: you are blocked by a choice someone else made in code you do not control.
What the lockfile actually records
The lockfile (package-lock.json, yarn.lock, or pnpm-lock.yaml) is the result of that resolution, written down. For every package in the tree, direct and indirect, it records the exact version that was chosen, where it was downloaded from, and an integrity hash of its contents. In npm’s lockfile it also marks packages that declare install scripts.
That has two consequences:
- Commit it. Without a lockfile, two developers installing a day apart can get different indirect dependencies, because ranges resolve to whatever is newest at that moment. With it, everyone and every CI run gets the same tree.
- Install from it in CI.
npm ciinstalls exactly what the lockfile says and fails if it does not matchpackage.json.npm installmay update it quietly.
The lockfile is also your most honest inventory. If you want to know what is in the build, that file is the answer, not package.json.
Reading the tree
You do not need extra tooling to start:
| Question | Command |
|---|---|
| What do my direct dependencies pull in? | npm ls --all |
| Why is this package here at all? | npm explain <package> |
| How many copies of it do I have? | npm ls <package> |
| Can duplicates be merged? | npm dedupe |
| What is known to be vulnerable? | npm audit |
| Export a formal inventory | npm sbom --sbom-format cyclonedx |
npm explain is the one teams underuse. It answers the most practical question in any dependency audit: which of our own choices is responsible for this package being here.
An example from our practice
Linkster is a no-code generator of microlandings and digital business cards for entrepreneurs and small businesses, built for a client in Tallinn, Estonia. We developed it from scratch on Vue.js.
A frontend product like this is the cleanest illustration of the topic we know. Nothing about its dependencies is unusual. A few direct choices (the framework, routing, state, a build tool, a test setup) routinely turn into hundreds of indirect dependencies, and that is the expected outcome, not a warning sign. Nobody on the team has read the code of most of those packages, which is the normal state of any modern frontend.
So the useful habits are about visibility rather than reduction:
- The lockfile is part of the repository and goes through review like any other file. A pull request that adds one direct dependency but changes a large section of the lockfile is a signal to look closer.
- Adding a direct dependency is a small decision that gets asked out loud: who maintains this, when was it last released, and what does it bring with it?
- When the tree needs attention, we start with
npm explainand trace the package back to the direct choice that brought it in, because that is the only level where we can actually change anything.
None of this slowed delivery on a project with a tight timeline. It happens at the moment a dependency is added, when the question costs a minute, rather than in a separate cleanup project later.
What to consider: what to remove and what to leave alone
A dependency audit is useful when it ends in a short list of concrete actions. It is harmful when it turns into a campaign to shrink a number.
Worth removing
- Abandoned packages. No releases, unanswered issues, a repository that is archived or gone. The risk is not that the code is bad today; it is that nobody will fix it when it is. If a direct dependency drags in abandoned code, look for a maintained alternative.
- Duplicates of the same function. Three date libraries or two HTTP clients usually mean three people solved the same problem at different times. Pick one and migrate.
- Packages with install scripts you do not need.
preinstallandpostinstallscripts run arbitrary code on every machine that installs the project. Some packages need them, for example to fetch a native binary. Many do not. Check which entries in the lockfile are marked as having install scripts, and ask whether each one has a reason. For CI, consider installing with--ignore-scriptsand allowing only the ones you need. - Direct dependencies that do one trivial thing. A package that wraps a single line of standard library code is a full supply chain entry point for almost no benefit.
Not worth removing
- Small, maintained utilities deep in the tree. Removing them usually means forking or replacing a direct dependency you depend on heavily, for a gain nobody will notice.
- Anything you would have to patch around. Overriding indirect dependencies (
overridesin npm) is a sharp tool for a specific security fix, not for tidying. - Packages chosen only for their size. Size is a weak signal. A tiny package with a single anonymous maintainer can be a bigger risk than a large one with an active team behind it.
The best single test for any package is whether it has a living author: someone who releases, answers issues, and would notice if their account published something unexpected. That matters more than the line count.
How this connects to supply chain attacks and SBOM
Supply chain attacks rarely target the libraries you chose. They target the ones you did not notice: a compromised maintainer account on a small indirect package, a malicious install script, a lookalike name. Indirect dependencies are attractive precisely because nobody reviews them.
An SBOM (software bill of materials) is the formal answer to “what is in the build.” It is the same information as the lockfile, in a standard format that scanners and customers can read. The value comes from having it ready on the day a new advisory appears and you need to know, within minutes, whether you are affected.
Treat third-party code the way you treat any external system. Our article on threat modeling for small teams describes how to mark trust boundaries around anything that runs code you do not own, and the same thinking applies to the dependency tree. And as we argue in Security by Design, dependency scanning is a control you can add later; the decision that is free to make early is fewer, better-chosen direct dependencies.
Conclusion
Transitive dependencies are not a problem because there are many of them. They are a problem when they are invisible. Commit the lockfile, install from it, learn to trace any package back to the direct choice that brought it in, and spend your audit time on abandoned packages, duplicates, and install scripts rather than on the total count. Judge each dependency by whether someone is still maintaining it. If you want a team that builds products with these habits from the first commit, take a look at our custom software development services.


