How to Reduce Your node_modules Size by 50%
·PkgPulse Team
TL;DR
Most projects can cut node_modules by 30-60% without removing a single dependency. The gains come from: deduplication (multiple versions of the same package), separating devDependencies from production installs, replacing heavy packages with lighter alternatives, and using pnpm's content-addressable storage across projects. A 500MB node_modules often has 150MB of duplicate lodash and old library versions hiding inside.
Key Takeaways
- Deduplication:
npm deduperemoves duplicate packages within a version range - Production installs:
npm ci --omit=devskips devDependencies in production - Heavy replacements: moment.js (72KB) → date-fns (3KB typical), lodash → native
- pnpm: stores packages once globally — 60% less disk across projects
depcheck: finds installed packages you don't actually import
Step 1: Audit What You Have
# See total node_modules size
du -sh node_modules/
# Common outputs: 300MB - 2GB
# Find the largest packages
du -sh node_modules/* | sort -rh | head -20
# Typical large offenders:
# node_modules/webpack ~100MB (devDependency, shouldn't be in production)
# node_modules/typescript ~60MB (devDependency)
# node_modules/moment ~72MB (should be date-fns)
# node_modules/@aws-sdk ~200MB (tree-shake it)
# Find duplicate packages
npm ls | grep UNMET
npm ls react | grep -v deduped # See if multiple React versions exist
# Or use: npm-check-duplicates
npx npm-check-duplicates
Step 2: Deduplicate
# npm: deduplicate hoisted packages
npm dedupe
# Finds packages that satisfy multiple version ranges with one version
# Example: pkg A needs lodash ^4.17.0, pkg B needs lodash ^4.15.0
# Before dedupe: 2 copies of lodash
# After dedupe: 1 copy (4.17.21 satisfies both)
# Check what changed:
git diff package-lock.json | grep '"resolved"' | wc -l
# pnpm: deduplication is the default
# pnpm uses a content-addressable store — no duplicates possible
pnpm dedupe # Explicit dedup for lockfile optimization
# yarn:
yarn dedupe
# Removes duplicate packages in .yarn/cache
Step 3: Remove Unused Packages
# depcheck — finds packages you import vs what's in package.json
npx depcheck
# Output example:
# Unused dependencies:
# * lodash (imported nowhere but package.json has it)
# * @types/express (but you have types in package — devDep only)
#
# Missing dependencies:
# * date-fns (imported but not in package.json — transitive dep you're relying on)
# Remove truly unused:
npm uninstall lodash
npm uninstall @unused-package/thing
# Also check: packages used only in one file that could use a native API
# Example: using `uuid` only for `uuid.v4()` → replace with `crypto.randomUUID()`
Step 4: Replace Heavy Packages
# Biggest wins by package replacement:
# moment.js (72KB) → date-fns (~3KB typical usage)
npm uninstall moment
npm install date-fns
# Savings: ~70KB bundle, significant node_modules reduction
# lodash (70KB) → native JavaScript
npm uninstall lodash
# Many lodash functions have native equivalents:
# _.map → Array.map
# _.filter → Array.filter
# _.reduce → Array.reduce
# _.find → Array.find
# _.get → optional chaining (?.)
# _.merge → Object.assign / spread
# _.debounce → keep (or use usehooks-ts/useDebounce)
# If you only need 5 functions, use lodash-es/treeShake or individual packages
# request (deprecated) → ky or native fetch
npm uninstall request
npm install ky # 3KB, modern, Promise-based
# chalk → colorette or picocolors (for CLI tools only)
npm uninstall chalk
npm install picocolors # 0.5KB vs chalk's 6KB
# uuid → native crypto.randomUUID()
npm uninstall uuid
# In code: crypto.randomUUID() works in Node 15+, Chrome 92+, Safari 15+
Step 5: Separate Dev vs Production
# Ensure devDependencies are correctly classified
# DevDeps: TypeScript, testing tools, bundlers, linters
# Deps: runtime libraries your app actually uses
# Check production install size vs full install:
npm ci --omit=dev # Production: skips devDependencies
du -sh node_modules/
# Should be 50-80% smaller than full install
# Common mismatch — things in "dependencies" that should be "devDependencies":
# - typescript
# - vitest / jest
# - @types/*
# - eslint, biome, prettier
# - webpack, vite, rollup
# - ts-node (use tsx for runtime)
# Fix:
npm install --save-dev typescript # Moves to devDependencies
Step 6: AWS SDK Optimization
# @aws-sdk v3 is modular — only import what you use
# Before (v2, whole SDK):
npm install aws-sdk # 200MB+, everything included
# After (v3, modular):
npm install @aws-sdk/client-s3 # Only S3
npm install @aws-sdk/client-dynamodb # Only DynamoDB
# Each client: ~5-15MB vs 200MB for full SDK
# Tree-shaking in bundled apps:
# Vite and webpack will tree-shake unused SDK exports automatically
# But in Node.js (unbundled), you still need the right package
Step 7: Switch to pnpm for Global Efficiency
# The pnpm advantage: global content-addressable store
# ~/.pnpm-store/v3/ — all packages stored by hash
# Multiple projects share packages — stored ONCE on disk
# Before pnpm (npm):
# project-a/node_modules: 300MB
# project-b/node_modules: 280MB
# project-c/node_modules: 310MB
# Total: 890MB
# After pnpm:
# project-a/node_modules: symlinks → 50MB (unique deps)
# project-b/node_modules: symlinks → 40MB (unique deps)
# project-c/node_modules: symlinks → 45MB (unique deps)
# ~/.pnpm-store: 400MB (shared by all)
# Total: 535MB — 40% savings
# Migration:
rm -rf node_modules package-lock.json
npm install -g pnpm
pnpm install # Creates pnpm-lock.yaml
Measuring Progress
# Before:
du -sh node_modules/ # 487MB
# Run all steps above
# After:
npm dedupe
npx depcheck # Remove unused
# Replace heavy packages
npm ci # Clean reinstall
du -sh node_modules/ # 241MB ← 50% smaller
# Bundle size impact (separate from node_modules):
npm run build
# Check dist/ size — this is what users download
# node_modules size ≠ bundle size (bundlers tree-shake)
Analyze npm package bundle sizes on PkgPulse.
See the live comparison
View npm vs. pnpm on PkgPulse →