Skip to main content

npm Dependency Trees: Most Nested Packages 2026

·PkgPulse Team
0

TL;DR

Installing create-react-app in 2022 added 1,200+ packages to your node_modules. Installing next@latest adds ~500. Installing zustand adds 1. Dependency depth matters for three reasons: supply chain attack surface, installation time, and version conflict probability. The packages with the deepest trees aren't always the most complex — sometimes they just have poor dependency hygiene. Here's how to audit yours.

Key Takeaways

  • create-react-app (deprecated): 1,200+ transitive deps
  • Next.js: ~500 transitive deps (complex build tooling)
  • Zustand, Hono, dayjs: 0-3 transitive deps
  • More deps ≠ more functionality — poor dependency hygiene inflates trees
  • npm ls --all shows your full dependency tree

The Dependency Tree Hall of Fame (And Shame)

# Check how many packages a dependency installs:
npm install --dry-run package-name 2>&1 | grep "added [0-9]* packages"

# Some examples:

npm install --dry-run zustand
# added 1 package — just zustand itself

npm install --dry-run hono
# added 1 package — zero deps

npm install --dry-run dayjs
# added 1 package

npm install --dry-run express
# added 57 packages

npm install --dry-run fastify
# added 8 packages

npm install --dry-run next
# added 524 packages (approximate)

npm install --dry-run create-react-app  # (deprecated)
# added 1,200+ packages — the famous example

# What this means for security:
# zustand: 1 package to audit
# express: 57 packages to audit
# next: 524 packages to audit (many are Vercel's own trusted packages)

The contrast between zustand's zero dependencies and create-react-app's 1,200+ packages illustrates two fundamentally different design philosophies. Zustand was built with deliberate minimalism — the author's stated goal was React state management without overhead, not just in API surface but in the dependency chain. Every utility function zustand could have pulled from another package was inlined instead, keeping the total footprint at a single package. That decision is invisible to users until they compare npm install output.

The create-react-app number requires historical context. CRA's mission was zero-configuration React development, which meant bundling a complete toolkit: webpack for bundling, Babel for transpilation, Jest for testing, ESLint for linting, plus all of their required plugins and presets. Each major tool carries its own dependency graph. The 1,200-package count was not negligence — it was the cost of shipping "batteries included" in 2018, before native toolchain support (Go-based esbuild, Rust-based SWC) existed.

The modern equivalent — Vite's approach — works differently. Vite delegates heavy compilation to esbuild, which is a single platform-specific binary with zero npm dependencies. The esbuild binary handles TypeScript, JSX, and bundling without creating npm dependency chains. This architectural pattern — using compiled binaries for computational hot paths rather than npm packages — is why Vite can provide a complete development experience with dramatically fewer transitive npm packages than CRA ever could.


Why Some Packages Have Deep Trees

Reasons for large dependency counts:

1. Complex functionality (legitimate):
   → Next.js needs: bundler, compiler, router, HMR, dev server, image optimizer
   → Each of these has sub-dependencies
   → This is acceptable — the complexity is the product

2. Poor dependency hygiene (avoidable):
   → Package depends on 20 utility packages for simple operations
   → Example: package depends on lodash for 2 utility functions
   → Could inline these 10-line functions but used a 70KB library
   → This is the avoidable case

3. Historical accumulation (legacy):
   → Old packages accumulated deps before npm had better hygiene culture
   → Some deps were added for compatibility reasons that no longer apply
   → Removing them = breaking change = maintainer defers

4. Transitive accumulation:
   → Package A depends on B
   → B depends on C, D, E
   → C, D, E each depend on F, G
   → Result: installing A = 8+ packages when A's own deps are just 1

5. Dev dependencies miscategorized:
   → Some packages ship devDependencies as dependencies
   → These get installed in production even though they shouldn't
   → This is a packaging bug, but common in older packages

The "poor dependency hygiene" category accumulated most visibly during the era from roughly 2014 to 2020, which produced packages that solved trivially small problems through full library dependencies. The is-odd package checks if a number is odd and itself depends on is-number. These micro-packages were not jokes — they reflected a genuine philosophy that small, well-tested behaviors should be shared rather than inlined. The philosophy made practical sense when JavaScript lacked modern language features: before optional chaining (?.), before nullish coalescing (??), before Array.prototype.includes, even simple safe property access required verbose boilerplate worth abstracting.

The issue is that the language caught up. Node 18+ includes fetch, structuredClone, Array.prototype.at, and Object.hasOwn natively. ECMAScript advanced enough that large categories of lodash utility functions became one-liners in modern JavaScript. Packages that haven't updated their dependency philosophy — that still reach for lodash.merge instead of { ...obj1, ...obj2 } — carry dependencies solving problems the platform now handles. The maintainer hasn't removed them because nothing is broken; they just add invisible weight.

The "devDependencies miscategorized as dependencies" problem is particularly costly because it inflates production install counts. A package that builds a CLI and lists its development-time code generator under dependencies instead of devDependencies forces every consumer to install build tools they'll never execute. This packaging error is common in older packages and is rarely fixed because it would require a major version bump to avoid breaking consumers who depend on the current behavior.


How to Audit Your Own Dependency Tree

# Full tree (warning: very long output):
npm ls --all

# Just direct dependencies:
npm ls --depth=0

# Find specific package in tree:
npm ls some-package
# Shows: which of your direct deps brought in some-package

# Count total packages:
npm ls --all 2>/dev/null | grep -c "node_modules/"
# Typical results:
# Simple project (5-10 deps): 200-500 packages
# Next.js project: 600-900 packages
# CRA (deprecated): 1,200+ packages

# Find duplicate packages (multiple versions):
npm ls --all 2>/dev/null | sort | uniq -d | grep -v "node_modules"
# Multiple versions = larger node_modules, potential conflicts

# Find the heaviest packages:
npx cost-of-modules --no-install
# Shows: size contribution of each package in node_modules

# Find packages that shouldn't be there:
npx depcheck
# Reports: installed packages not referenced in code
# Often reveals dev-time packages accidentally in dependencies

A dependency audit typically surfaces three actionable categories. The first is direct dependencies that should be devDependencies: build tools, type definitions (@types/*), linters, test frameworks, code generators. These don't belong in production environments. Moving them requires no code changes but reduces production install time and attack surface.

The second category is chains traceable to a single direct dependency. npm ls some-package shows which direct dependency pulled in a specific transitive package. If a problematic transitive dependency comes from one direct dependency you're not heavily invested in, replacing that direct dependency with a lighter alternative may eliminate the entire chain. This is the high-leverage move: one replacement, many packages removed. Replacing a utility library that trails 30 transitive packages with a zero-dependency alternative removes all 30 in one change.

The third category — packages depcheck reports as installed but never imported — are the fastest wins. These are listed in package.json but depcheck cannot find any import or require of them in your source. They may be historical dependencies from removed features, packages superseded by others that were never cleaned up, or CLI tools that shouldn't be under dependencies. Each removal eliminates its entire subtree from your installation.

The standard audit workflow — npm ls --depth=0 to survey your direct dependencies, then npm ls specific-package to trace transitive origins, then depcheck for orphans — takes about 30 minutes in a typical project and reliably finds actionable improvements in projects that have never been audited.


Dependency Depth Examples

Illustrative dependency chains:

zustand → 0 deps
→ Just zustand.

express → 4 direct deps
express
├── accepts (2 deps)
├── body-parser (4 deps)
├── cookie (0 deps)
└── path-to-regexp (0 deps)

fastify → 8 direct deps (all @fastify/ scope, well-controlled)
fastify
├── @fastify/ajv-compiler (2 deps)
├── @fastify/error (0 deps)
├── @fastify/fast-json-stringify (3 deps)
└── ... (all owned by Fastify team)

create-react-app (when it existed): 1,200+ transitive deps
Because it bundled: webpack, babel, jest, postcss,
eslint, webpack-dev-server, plus ALL of their deps,
plus ALL of those deps' deps...

The lesson: tools that bundle entire toolchains
(CRA, old Next.js, Angular CLI) have the deepest trees.
Modern tools outsource dependency management:
→ Vite: uses esbuild (Go binary, not npm tree)
→ Next.js: uses SWC (Rust binary, minimal npm tree)
→ Drizzle: SQL-first, no ORM framework overhead

The Fastify example illustrates what deliberate dependency discipline looks like at scale. Fastify provides a richer API than Express — built-in JSON schema validation, a plugin system, async-native handlers — with dramatically fewer transitive dependencies. The @fastify/ namespace is intentional: the Fastify team controls all first-party plugins under their own scope, making the dependency graph auditable. Every dependency is from the same team, with the same security maintenance cadence and release philosophy.

The "modern tools use binaries" observation has a concrete implication when evaluating build tools. Whether a tool delegates its heavy computation to an npm package or to a compiled binary is a reliable proxy for its transitive dependency count. Tools that compile, transform, or link through npm packages — older webpack setups, babel-heavy configs — create long npm chains. Tools that shell out to esbuild, SWC, or Rust-based compilers inherit essentially nothing from npm for those operations. Biome (a Rust-based linter and formatter) installs as a single platform binary with zero npm transitive dependencies. Comparing it to ESLint plus Prettier plus all their plugins makes the cost difference concrete: one package versus 40-60+.

The implication for architecture decisions: when choosing between two tools that provide similar functionality, the one that uses compiled binaries for heavy lifting will almost always have a smaller npm dependency footprint. This is a signal worth looking at alongside bundle size, performance benchmarks, and ecosystem support.


The Version Conflict Problem

# Deep trees = higher chance of version conflicts
# Two packages need different major versions of the same dep:

# Example:
# my-app
# ├── package-a@1.0 → uses lodash@3
# └── package-b@2.0 → uses lodash@4

# Result: npm installs BOTH lodash@3 and lodash@4
# node_modules/
# ├── lodash@4 (used by default)
# └── node_modules/package-a/node_modules/lodash@3 (nested)

# Cost:
# - Two copies of lodash in bundle (doubled bundle size)
# - Potential type errors (TypeScript sees both versions)
# - Bug potential if types differ between versions

# Detect duplicate packages:
npm ls 2>&1 | grep -E "WARN.*peer|invalid"
# Shows: peer dep conflicts

# Or:
npm dedupe  # Deduplicate where possible
# Reduces duplicate packages if version ranges are compatible

# Better: pnpm handles this better than npm
# pnpm: hard links, content-addressable store
# npm: copies, can have multiple versions in node_modules

npm's resolution strategy for version conflicts — install the highest compatible version at the top level, nest incompatible versions inside the requiring package — produces the hoisted node_modules structure that makes JavaScript dependency management distinctive. When two dependencies need incompatible versions of the same package, npm creates a nested copy inside the requiring package's node_modules. This is transparent to users but doubles the disk footprint for that package and can create type-checking problems when TypeScript sees two different versions of the same package's type declarations.

pnpm addresses this with a content-addressable store. It maintains a single copy of every package version globally, then creates symlinks in each project's node_modules to the store. Two effects follow: disk usage is significantly lower when multiple projects share the same packages, and the node_modules layout more accurately reflects the actual dependency graph rather than npm's flattened structure. The accurate layout prevents phantom dependencies — your code cannot accidentally import a transitive dependency that isn't in your package.json, because it isn't hoisted to the top level.

For large monorepos, pnpm's approach to deduplication can reduce node_modules disk usage by 30-50% compared to npm. npm dedupe provides a similar benefit within a single project when compatible version ranges exist, but it operates on the install that already happened rather than preventing the duplication at the resolution step the way pnpm does.


Strategies for Minimizing Dependency Depth

# Strategy 1: Choose packages with fewer deps
# Before installing: check dep count
npm view package-name --json | jq '.dependencies | length'
# 0 = best, <5 = good, >10 = investigate

# Strategy 2: Prefer bundled binaries over npm packages for tooling
# esbuild: Go binary (1 npm package, no transitive deps)
# SWC (used by Next.js): Rust binary
# These have zero npm transitive deps despite being complex tools

# Strategy 3: Don't install production build tools as runtime deps
# webpack, vite, esbuild → devDependencies only
# Reduces production runtime dependency count significantly

# Strategy 4: pnpm for better deduplication
# pnpm install → content-addressable store
# No duplicate packages even if two deps need same-version package

# Strategy 5: Audit and remove unused dependencies
npx depcheck  # Find unused direct dependencies
# Each removed dependency removes its entire subtree

# Strategy 6: Override vulnerable transitive packages
# package.json:
{
  "overrides": {
    "vulnerable-package": ">=patched-version"
  }
}
# Doesn't reduce count but fixes vulnerabilities

The strategy list gives the tools, but the underlying principle is worth stating: every dependency is a trust delegation. You are trusting the maintainer to apply security patches promptly, avoid breaking changes without major version bumps, and make dependency decisions you would agree with in future releases. A zero-dependency package with a long track record is a lower-trust-delegation risk than a package with twenty dependencies maintained by teams you cannot evaluate.

The "choose packages with fewer deps" strategy is more practical than it sounds because the npm ecosystem now has enough alternatives that comparison shopping is feasible. A package comparison that includes dependency count alongside bundle size, weekly downloads, and GitHub activity gives materially better signal than any of those metrics alone. A package with 5M weekly downloads and 20 dependencies is not automatically better than one with 2M downloads and 2 dependencies — the dependency count is load-bearing information about the ongoing risk profile.

The highest-value moment to check dependency counts is before adding a package, not after it's entrenched. npm install --dry-run package-name before committing the addition shows the full installation cost. Teams that normalize this one-step check during PR review dramatically reduce the rate at which dependency trees grow uncontrolled. Once a deep tree exists, untangling it requires effort proportional to how long it went unmanaged.


Visualizing Your Dependency Tree

# Install visualization tool:
npm install -g dependency-cruiser

# Generate visual graph:
depcruise --exclude node_modules --output-type dot src | dot -T svg > dep-graph.svg

# Or: use npm ls with filtering
# Direct deps only:
npm ls --depth=0

# Deps that changed between installs:
git diff package-lock.json | grep "+  "  # New packages added

# Check for deep nesting:
npm ls --all 2>/dev/null | awk '{print NF}' | sort -n | tail -5
# Shows: deepest nesting level in your tree
# Most projects: max depth 8-15
# CRA-era projects: 20+

# Find packages with most dependents IN your project:
npm ls --all 2>/dev/null | sort | uniq -c | sort -rn | head -20
# Packages appearing many times = used by many of your deps = risky to remove

Visualization is most valuable for identifying which packages are responsible for the longest chains — not just that chains exist. In a dependency graph, packages with many arrows pointing to them are shared by multiple of your other dependencies; they are load-bearing and difficult to remove without coordinating across all their dependents. The actionable findings are usually in the medium-length chains that originate from a single direct dependency you control.

Working backward from the deepest node in the tree to the direct dependency that started it is the key diagnostic. The npm ls lodash command, for example, shows every path from your direct dependencies to lodash — which of your deps requires it, and through which chain. If one of your less-critical direct dependencies is the sole path to a problematic transitive package, replacing it is straightforward.

For most projects, the visualization reveals that the majority of package count comes from one or two toolchain dependencies — the bundler, the testing framework, or a component library. These large-count dependencies typically have legitimate complexity (it's why you installed them), so the actionable finding is usually in the medium-depth packages: libraries with 5-15 transitive dependencies where a lighter alternative exists. Targeting those is more achievable than overhauling the toolchain, and can remove hundreds of packages from the tree in a series of manageable changes.


The Dependency Depth Hall of Fame (2026)

Not every package with a deep dependency tree earned it through legitimate complexity. Express's 57 packages represents the pre-modern npm era — a large collection of utility middleware was normal in 2012. Splitting every utility function into its own npm package was the prevailing philosophy before the JavaScript community developed better hygiene instincts. Express itself is small, but each of its dependencies brought its own small army of utility packages that solved problems Node.js's stdlib handles natively today.

Webpack has a notably deep tree due to its plugin architecture and the variety of utilities it integrates. Its job is to understand every possible JavaScript module format, apply transformations, and optimize output — that complexity requires many specialized packages. Babel's tree reflects its modular plugin-based architecture: each transformation is a separate package. Installing @babel/core alone brings in dozens of packages because each visitor plugin, parser utility, and code generator component lives independently.

The contrast with modern tooling is stark. Vite has a much smaller tree because it uses esbuild (written in Go, not npm-dependent) for fast development builds and Rollup for production builds. The heavy lifting happens in compiled binaries, not npm packages. The esbuild binary installs as a single platform-specific package with zero transitive dependencies despite being a complete JavaScript bundler.

The key numbers give useful benchmarks: the typical medium-sized React application has 800–1,500 packages in its full dependency tree. A well-optimized project using zero-dependency alternatives and minimal tooling can cut this to 300–500 packages without sacrificing functionality. The npm ls --depth 10 command shows the full dependency tree to a practical depth. For a visual representation, npx depcruise --output-type json --no-config . | npx depcruise-viz | open - generates an interactive dependency graph that makes the tree structure navigable — useful for identifying which of your direct dependencies is responsible for the longest chains.

Next.js sits in an interesting middle ground. Its 500+ transitive packages are not the result of poor dependency hygiene — they reflect the genuine complexity of a full-stack framework that handles routing, server-side rendering, image optimization, internationalization, edge runtime deployment, and more. Many of those packages are from the Vercel team itself and receive consistent security maintenance. The practical attack surface from a Next.js installation is substantially lower than a random collection of 500 community packages would suggest, because the supply chain is concentrated in a well-resourced organization. Context matters: 500 packages from one trusted team is a very different risk profile from 500 packages from 500 different authors.


Practical Strategies for Reducing Dependency Depth

Shrinking your dependency tree is one of the highest-leverage maintenance tasks available — it reduces attack surface, speeds up npm install, reduces version conflict probability, and decreases the number of packages you're implicitly committing to maintain compatibility with over time. It requires no feature work and produces compounding benefits: fewer packages means fewer future security advisories, fewer update cycles, and fewer unexpected breakages when transitive packages publish incompatible changes.

Strategy 1: Replace utility libraries with Node.js stdlib. lodash has 0 dependencies itself but ships 51KB — using individual lodash/{function} imports reduces the bundled weight, but switching to native array methods, Object.entries, and structuredClone removes the dependency entirely. axios can be replaced by native fetch for basic HTTP requests in any project targeting Node 18+. The question is always: does this library do something the platform cannot?

Strategy 2: Audit your direct dependencies. npm ls --depth 0 shows only your direct dependencies. Each one trails a tree of its own. For each direct dependency, ask: does this need to be a runtime dependency, or could it be a devDependency? Many build tools, code generators, and CLI utilities get accidentally listed under dependencies rather than devDependencies, meaning they install in production environments that don't need them.

Strategy 3: Use bundlephobia.com to check how many packages a dependency brings. The dependency count shown on bundlephobia surfaces the full transitive cost of adding a package — not just the bundle size.

Strategy 4: Deduplicate with npm dedupe. This command resolves duplicate package versions where version ranges are compatible, often reducing total package count by 10–20%. It's safe to run and doesn't change your package.json — only the lockfile.

Strategy 5: Prefer "batteries not included" packages. Choose packages that let you opt into features rather than ones that ship everything by default. A router that requires plugins for middleware is a smaller dependency than one that bundles every middleware variant in its core. The plugin architecture means you pay only for what you use — both in bundle size and in dependency count — which compounds measurably across a project's full lifetime as unused capabilities never accumulate in the dependency tree.


Compare dependency counts and package health at PkgPulse.

See also: Express vs NestJS and Bun vs Vite, The Hidden Cost of npm Dependencies.

See the live comparison

View pnpm vs. npm on PkgPulse →

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.