Skip to main content

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 --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)

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.