How to Reduce Your node_modules Size by 50%
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
Why node_modules Gets So Large
A freshly created Next.js application has a node_modules folder approaching 300MB before you add a single dependency. Add TypeScript, ESLint, testing tools, a UI library, and a few utility packages and you're at 600MB. Docker images with unbounded node_modules can reach 1GB or more.
The size accumulates in layers. First, devDependencies: TypeScript, Webpack, Vite, Jest, ESLint, and their transitive dependencies are typically 60-70% of your total node_modules size, yet they're never needed in production. Second, duplicates: npm's hoisting algorithm minimizes duplication but doesn't eliminate it — multiple packages depending on different (but semver-compatible) versions of the same library results in multiple copies on disk. Third, legacy package choices: some packages that were the best option five years ago are now significantly larger than modern alternatives.
Addressing all three categories systematically can reduce a 500MB node_modules to 200-250MB. For Docker images and Lambda deployments, these savings translate directly to faster deployments, lower cold start times, and reduced storage costs.
Step 1: Audit What You Have
Before optimizing anything, measure. You need to know where the bytes are coming from before deciding which techniques will have the most impact:
# See total node_modules size
du -sh node_modules/
# Find the largest packages (top 20)
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 (needs tree-shaking)
# node_modules/@mui ~150MB (check if fully used)
Beyond size, check for unused and duplicate packages:
# Find unused packages (installed but not imported)
npx depcheck
# Output example:
# Unused dependencies:
# * lodash (imported nowhere but in package.json)
# * @types/express (but you have types in your packages — devDep only)
#
# Missing dependencies:
# * date-fns (imported but not in package.json — transitive dep you're relying on)
# Find duplicate packages
npm ls react | grep -v deduped # See if multiple React versions exist
# More comprehensive duplicate detection:
npx npm-check-duplicates
# Top-level dependencies only (easier to review):
npm ls --depth=0
The depcheck output is particularly valuable. Unused dependencies are packages you can remove immediately — they're in package.json but never imported anywhere. These are typically packages from features that were removed, tools that were replaced, or copy-paste additions from other projects.
Technique 1: Deduplication
npm's hoisting algorithm minimizes duplicates but doesn't eliminate them. Multiple packages can each depend on different semver-compatible versions of the same library, resulting in multiple copies:
# Example: 5 packages all need lodash, but specify different ranges
# Package A: "lodash": "^4.17.0" → gets 4.17.20
# Package B: "lodash": "^4.15.0" → could get 4.17.20 (same), or 4.15.2 (different)
# Before dedupe: potentially 2+ copies of lodash in node_modules
# npm: deduplicate hoisted packages
npm dedupe
# Check what changed — count resolved package changes:
git diff package-lock.json | grep '"resolved"' | wc -l
# pnpm: deduplication is the default behavior
# pnpm uses a content-addressable store — no duplicates possible per definition
pnpm dedupe # Explicit dedup for lockfile optimization
# yarn:
yarn dedupe
# Removes duplicate packages in .yarn/cache
npm dedupe rewrites your package-lock.json to use fewer package versions. It finds cases where multiple semver ranges can all be satisfied by a single version and consolidates them. For a large application with deep dependency trees, this can recover 20-50MB.
Technique 2: Production Install
This is the highest-impact single change for most projects. devDependencies (TypeScript, ESLint, testing frameworks, bundlers) are typically 60-70% of total node_modules size. They should never be in your production Docker image or Lambda deployment:
# 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 misclassifications — these are frequently found in dependencies when they should be in devDependencies:
# Move these to devDependencies if they're currently in dependencies:
npm install --save-dev typescript
npm install --save-dev vitest
npm install --save-dev @types/node
npm install --save-dev eslint
npm install --save-dev prettier
npm install --save-dev vite
npm install --save-dev webpack
npm install --save-dev ts-node # Use tsx for runtime TypeScript execution
For Docker images, the optimization looks like this:
# BEFORE — all dependencies in production image
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci # Includes all devDependencies
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
# node_modules: ~500MB
# AFTER — multi-stage build, production only
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # Full install needed to build
COPY . .
RUN npm run build
FROM node:22-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev # Production deps only
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
# node_modules: ~150MB — 70% smaller
The multi-stage build is the cleanest approach: install everything in the build stage, compile your TypeScript, then create a fresh production image that only has runtime dependencies and the compiled output.
Technique 3: Replace Heavy Packages
Some popular packages have become significantly heavier than their modern alternatives. A few targeted replacements can save 100MB or more:
| Old Package | Size | Replacement | Size | Notes |
|---|---|---|---|---|
| moment | ~72KB bundle | date-fns | ~3-20KB (tree-shakeable) | Import only what you use |
| moment | ~72KB bundle | Temporal API | 0KB | Native browser/Node API |
| lodash | ~72KB bundle | lodash-es | Depends on imports | Tree-shakeable version |
| lodash | ~72KB bundle | Native JS | 0KB | Most functions have equivalents |
| request (deprecated) | ~550KB bundle | native fetch | 0KB | Built into Node 18+ |
| request (deprecated) | ~550KB bundle | ky | ~3KB | Modern, promise-based |
| uuid v4 only | ~30KB bundle | crypto.randomUUID() | 0KB | Built-in, Node 15+ |
| chalk | ~6KB bundle | picocolors | ~0.5KB | CLI-only use case |
| axios | ~15KB bundle | native fetch | 0KB | Node 18+, most use cases |
# moment.js → date-fns
npm uninstall moment
npm install date-fns
# Import only the functions you use:
# import { format, parseISO, addDays } from 'date-fns';
# lodash → native JavaScript
npm uninstall lodash
# Most functions have direct native equivalents:
# _.map → Array.prototype.map
# _.filter → Array.prototype.filter
# _.reduce → Array.prototype.reduce
# _.find → Array.prototype.find
# _.get → optional chaining (?.)
# _.merge → Object.assign or spread
# _.cloneDeep → structuredClone() (Node 17+)
# _.debounce → keep (no native equivalent — use lodash-es for just this)
# uuid → native crypto.randomUUID()
npm uninstall uuid
# Replace: import { v4 as uuidv4 } from 'uuid';
# With: crypto.randomUUID()
# Works in Node 15+, Chrome 92+, Safari 15+
# request (deprecated) → native fetch
npm uninstall request
# Replace: const response = await request.get(url)
# With: const response = await fetch(url)
The lodash replacement deserves extra care. If you're using lodash heavily throughout a codebase, removing it entirely is a large refactor. A better intermediate step is switching to lodash-es — the ES modules version that is fully tree-shakeable. Your bundler (Vite, webpack, esbuild) will include only the functions actually imported, dramatically reducing bundle size without requiring code changes.
Technique 4: Switch to pnpm
pnpm's content-addressable global store changes the disk usage equation entirely. Instead of each project having its own copy of every package, all projects share a single global store at ~/.pnpm-store/. Project node_modules folders contain only symlinks to the store.
The disk savings compound across projects:
# Before pnpm (npm):
# project-a/node_modules: 300MB (full copies)
# project-b/node_modules: 280MB (full copies)
# project-c/node_modules: 310MB (full copies)
# Total on disk: ~890MB
# After pnpm:
# project-a/node_modules: symlinks → ~20MB unique deps
# project-b/node_modules: symlinks → ~18MB unique deps
# project-c/node_modules: symlinks → ~22MB unique deps
# ~/.pnpm-store: ~400MB (shared by all)
# Total on disk: ~460MB — 48% savings
Migration from npm to pnpm takes about 5 minutes:
# Migration:
rm -rf node_modules package-lock.json
npm install -g pnpm
pnpm install # Creates pnpm-lock.yaml, builds the global store
du -sh node_modules/ # Reports symlink size, not real disk usage
Note that du -sh node_modules/ will report a small number after switching to pnpm — it's measuring the symlinks, not the actual package data in the global store. The real disk usage is in ~/.pnpm-store/. This can be confusing the first time you see it, but it reflects the genuine storage efficiency.
Technique 5: AWS SDK Optimization
The AWS SDK is one of the most common sources of extreme node_modules bloat. The v2 SDK (aws-sdk) is a monolith that includes every AWS service client in a single package — approximately 200MB:
# AWS SDK v2 (avoid for new projects):
npm install aws-sdk # 200MB+, everything included
# AWS SDK v3 (modular — install only what you need):
npm install @aws-sdk/client-s3 # Only S3 (~5MB)
npm install @aws-sdk/client-dynamodb # Only DynamoDB (~8MB)
npm install @aws-sdk/client-ses # Only SES (~4MB)
# Each client: ~5-15MB vs 200MB for full v2 SDK
# If you're still on v2, migration saves ~180MB per Lambda function
If you're deploying Lambda functions and still using the v2 SDK, migrating to v3 is one of the highest-ROI changes you can make. Lambda cold starts are directly affected by package size — smaller functions start faster.
Technique 6: Docker Layer Caching
The order of operations in your Dockerfile determines how often the node_modules layer is rebuilt. The classic mistake is copying all source files before installing dependencies:
# WRONG — any source change triggers full reinstall
FROM node:22-alpine
WORKDIR /app
COPY . . # All files (source changes bust the cache)
RUN npm ci # Runs every time any source file changes
RUN npm run build
CMD ["node", "dist/index.js"]
# CORRECT — source changes don't bust the dependencies layer
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # Only copy lock files first
RUN npm ci # Only runs when deps change
COPY . . # Source files added after
RUN npm run build
CMD ["node", "dist/index.js"]
Copy package.json and the lockfile first, run npm ci, then copy source files. Docker's layer cache means the npm ci step only runs when the lockfile changes — not on every source file edit. In a CI environment that builds Docker images frequently, this can save minutes per build.
Measuring Results
Track your progress at each step with concrete before/after measurements:
# Before starting — record baseline:
du -sh node_modules/ # e.g., 487MB
# After deduplication:
npm dedupe
du -sh node_modules/ # e.g., 440MB (saved ~47MB)
# After removing unused packages (depcheck output):
npm uninstall unused-pkg1 unused-pkg2
du -sh node_modules/ # e.g., 410MB
# After package replacements (moment → date-fns, etc.):
du -sh node_modules/ # e.g., 320MB
# After fixing devDependency misclassifications:
npm ci --omit=dev
du -sh node_modules/ # e.g., 180MB (this is your production size)
# Clean reinstall to verify:
rm -rf node_modules
npm ci
du -sh node_modules/ # e.g., 490MB full / 183MB production
The production install size (--omit=dev) is the number that matters for deployments. The full install size matters for developer machine disk usage, which pnpm addresses separately through its global store.
Package Health
| Tool | Weekly Downloads | What It Does | When to Use |
|---|---|---|---|
| depcheck | ~800K | Finds unused/missing dependencies | Audit before cleanup |
| npm-check | ~200K | Interactive upgrade + cleanup | Manual maintenance |
| pnpm | ~5M | Content-addressable package manager | Always, for disk efficiency |
| bundlephobia (web) | — | Checks bundle size before installing | Before adding new packages |
Technique 7: Understand node_modules vs Bundle Size
A common source of confusion: node_modules size and your application's bundle size are different things, and the techniques that reduce one don't necessarily reduce the other.
node_modules size affects: disk usage on developer machines, Docker image size, Lambda deployment package size, and cold start times. This is what the techniques above address.
Bundle size (the JavaScript your users download) is a separate concern addressed by tree-shaking in your bundler. A package can be 72KB in node_modules but contribute only 3KB to your bundle if your bundler tree-shakes it correctly. The package-size impact for server-side code (APIs, scripts) is node_modules size. For browser-delivered code, it's bundle size.
# Measure bundle size (what users actually download):
npm run build
du -sh dist/
# For Next.js:
# Check .next/analyze/ with @next/bundle-analyzer
npm install -D @next/bundle-analyzer
# For Vite:
# vite-bundle-visualizer shows bundle composition
npx vite-bundle-visualizer
Impact on CI and Deployment
Reducing node_modules has compounding benefits beyond disk space. Every CI run that installs dependencies pays a time cost proportional to what's being installed. A 500MB install might take 60-90 seconds on a clean CI runner; a 150MB production install takes 15-20 seconds. Across hundreds of CI runs per week, this adds up to meaningful wall-clock time savings.
For serverless deployments, the impact is even more direct. AWS Lambda has a 250MB unzipped deployment package limit. Many teams have hit this limit with the AWS v2 SDK alone. A Lambda that takes 1-2 seconds to cold start because of a bloated package is a Lambda that fails latency SLAs. The techniques in this guide — particularly devDependency pruning, AWS SDK v3, and multi-stage Docker builds — are the first steps when a Lambda deployment runs into size or cold-start issues.
The pattern for a Node.js Lambda is: install only production dependencies (npm ci --omit=dev), bundle everything with esbuild or the Lambda bundler plugin for webpack/rollup, and deploy the bundled output (a few hundred KB) rather than raw node_modules (hundreds of MB). The bundled output is tree-shaken, minified, and typically 50-100x smaller than the raw node_modules it was built from.
Summary: Prioritized Action List
If you're starting from a large node_modules and need quick wins, work through these in order of impact:
- Run
npm ci --omit=devand measure the production size. This single change typically cuts 50-70% of total size. - Fix devDependency misclassifications — move TypeScript, ESLint, Jest, and build tools to
devDependencies. - Run
npx depcheckand uninstall packages you don't import. - Replace moment with date-fns if moment is in your dependencies.
- Switch from AWS SDK v2 to v3 if you're using AWS services.
- Run
npm dedupeto clean up duplicate package versions. - Switch to pnpm for global disk savings across all your projects.
- Implement multi-stage Docker builds if deploying via containers.
For more on package management efficiency, read the guide to choosing npm, pnpm, or Yarn in 2026, explore pnpm's download trends and bundle size on the pnpm package page, or see the migration guide for switching from Webpack to Vite to reduce build tooling overhead.
See the live comparison
View npm vs. pnpm on PkgPulse →