The Hidden Cost of npm Dependencies
TL;DR
Every dependency is a liability disguised as a feature. That 2KB utility library you installed has 15 transitive dependencies, adds 47KB to your bundle when bundled naively, hasn't been updated in 8 months, and has a copyleft license clause you didn't read. The hidden costs of dependencies are real, measurable, and often ignored until they cause problems. This article gives you the framework to evaluate the full cost before npm install.
Key Takeaways
- Average production app: ~1,000 transitive dependencies — you didn't choose most of them
- Bundle cost — a package's actual bundle impact includes all its dependencies
- Security surface — each transitive dep is a supply chain attack vector
- License risk — GPL/LGPL licenses in transitive deps can affect your commercial code
- Maintenance debt — abandoned packages silently accumulate CVEs
- Breaking changes — every major dependency version update is a migration task
The Full Cost Model
When you npm install <package>, you're accepting:
Direct costs:
1. Bundle size impact (the package + its dependencies)
2. Install time (affects CI speed)
3. Learning curve (API, patterns, mental model)
Hidden costs:
4. Security surface (every dep is a potential supply chain attack)
5. License obligations (some deps have viral licenses)
6. Maintenance burden (breaking changes require code updates)
7. Transitive dependencies (the package's dependencies' dependencies)
8. Lock-in (migrating away later is always harder than expected)
Quantifying Hidden Costs
Cost 1: Bundle Size (With Dependencies)
# bundlephobia.com shows you the REAL bundle impact:
# What you think you're installing:
axios: "HTTP client, ~14KB gzipped"
# What actually gets bundled:
axios + follow-redirects + form-data + combined-stream + asynckit = ~32KB
# vs native fetch: 0KB (built into every modern environment)
# More examples:
moment.js: "72KB gzipped" — but the default import includes ALL locales
date-fns: "75KB gzipped" — but with tree-shaking: ~8KB for format + parseISO
lodash: "71KB gzipped" — but import { groupBy } from 'lodash-es': ~2KB
# The lesson: check bundlephobia before every install
// Dependency audit: check your actual bundle impact
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
open: true,
gzipSize: true,
filename: 'dist/bundle-stats.html',
}),
],
});
// Run: vite build → opens interactive treemap
// Shows which dependencies dominate your bundle
Cost 2: Transitive Dependencies
# Visualize the dependency tree
npm ls --depth=3
# Count transitive dependencies
npm ls --json | jq '[.. | objects | .name? // empty] | length'
# Find which deps bring the most transitive deps
npx depcheck # Check for unused dependencies
npx npm-why <package> # Why is this transitive dep installed?
# Real example: create-react-app's node_modules
# CRA installs ~1,200 packages for a "hello world" app
# Vite equivalent: ~80 packages
# That's 1,120 packages you never asked for, each with its own security surface
Cost 3: Security Surface
# Measure your attack surface
npm ls | wc -l # Count all packages (including transitive)
npm audit --json | jq '.metadata.vulnerabilities'
# Output: { info: 0, low: 2, moderate: 5, high: 1, critical: 0 }
# The relationship: more deps = more CVEs
# Real correlation from npm's data:
# Apps with 100-200 deps: average 2.3 vulnerabilities
# Apps with 500-1000 deps: average 11.7 vulnerabilities
# Apps with 1000+ deps: average 28.4 vulnerabilities
# Every transitive dep you add increases this number
Cost 4: License Risk
# Check licenses before installing
npx license-checker --summary
# Output:
# MIT: 847 packages
# ISC: 156 packages
# BSD-3-Clause: 89 packages
# Apache-2.0: 34 packages
# GPL-3.0: 2 packages ← FLAG THIS
# Unknown: 5 packages ← FLAG THIS
# GPL risk in commercial products:
# GPL-3.0 packages: if you distribute software including a GPL library,
# your code may need to be GPL too (consult a lawyer)
# LGPL: more permissive, but dynamic linking rules are complex
# AGPL: includes SaaS/network use — very viral
# Most npm packages are MIT, ISC, Apache (permissive = safe)
# Flag and review any GPL, LGPL, AGPL, or unknown licenses
# Tools:
npx license-checker --onlyAllow 'MIT;ISC;Apache-2.0;BSD-3-Clause;BSD-2-Clause;CC0-1.0;Unlicense'
# Fails CI if any dep uses a non-permissive license
The Dependency Cost Calculator
Before installing any package:
# Step 1: Do I need this?
# Can I implement this without a dependency?
# Examples of "no dependency needed" in 2026:
# - UUID: crypto.randomUUID() (Node.js 19.6+, browsers)
# - Fetch: native fetch (Node.js 18+)
# - Deep clone: structuredClone() (Node.js 17+)
# - Debounce: 8 lines of code
# - Flatten array: arr.flat()
# - Remove duplicates: [...new Set(arr)]
# - Get object values: Object.values(obj)
# Step 2: Check the real bundle impact
npx bundlephobia <package>
# Step 3: Count transitive dependencies
npm show <package> dependencies
# If it has > 5 direct deps, check bundlephobia for the real cost
# Step 4: Check health
# → pkgpulse.com/package/<package>
# → Last published? Maintained? CVEs?
# Step 5: Check license
npm show <package> license
# Anything not MIT/ISC/Apache-2.0/BSD? Research further.
# Step 6: Install decision:
# Small (< 5KB), no deps, MIT, maintained, needed → YES
# Large (> 50KB), many deps, abandoned → NO or find alternative
The One-Dependency Rule
For some categories, use zero dependencies:
// Replace these with native alternatives:
// UUID (before: uuid package, 2KB)
import { v4 as uuidv4 } from 'uuid';
uuidv4()
// After: native (0KB)
crypto.randomUUID()
// Deep clone (before: lodash.clonedeep, 9KB)
import cloneDeep from 'lodash/cloneDeep';
// After: native (0KB)
structuredClone(obj)
// Array shuffle (before: lodash.shuffle, 2KB)
// After: Fisher-Yates, 5 lines
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// Debounce (before: lodash.debounce, 2KB; or debounce package)
// After: 8 lines
function debounce<T extends (...args: unknown[]) => void>(fn: T, ms: number): T {
let timer: ReturnType<typeof setTimeout>;
return ((...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
}) as T;
}
// String padding (before: left-pad infamously broke npm in 2016)
// After: native
'hello'.padStart(10, ' ')
When Dependencies Are Worth It
Not every dependency should be avoided:
# Worth adding despite costs:
React (25M/wk, 45KB):
Cost: 45KB bundle, 2M+ transitive users' shared dep
Benefit: JSX, component model, 10-year ecosystem
Verdict: Core framework — worth it
Zod (12M/wk, 12KB):
Cost: 12KB bundle, 0 transitive deps
Benefit: TypeScript-safe validation, type inference
Verdict: Excellent ROI
Prisma (5M/wk):
Cost: ~500KB generated client, ~15 transitive deps
Benefit: Full ORM with migrations, type safety, Studio
Verdict: Worth it if you're using a database
lodash.groupby (cherry-picked, 2KB):
Cost: 2KB
Benefit: Well-tested, edge cases handled
Verdict: Fine
lodash (full, 70KB):
Cost: 70KB, unnecessary for single functions
Benefit: Same as cherry-picking
Verdict: Use cherry-picking instead
Check dependency impact and health scores on PkgPulse.
See the live comparison
View react vs. vue on PkgPulse →