Dependencies Deep Dive: The Most Nested Dependency Trees
·PkgPulse Team
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 --allshows 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)
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
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
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 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
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
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
Compare dependency counts and package health at PkgPulse.
See the live comparison
View pnpm vs. npm on PkgPulse →