A developer Mac that has shipped two years of side projects almost always has more bytes in node_modules than in the user's actual code. The exact number for one full-disk autopsy was 73 GB across 184 folders, on a 512 GB MacBook with 9 GB free. The reclaim was bigger than Xcode DerivedData and bigger than the local Ollama cache. The pain is so common that the dev who wrote up the brtkwr.com cleanup that reclaimed 200 GB from a full disk captured it in one sentence: "After the initial Docker/cache cleanup freed 150GB, going back and asking 'what else?' found another 75GB." Node modules are usually a big chunk of that second pass.
Why does node_modules grow so big on a Mac?
Every Node project duplicates the same packages locally. A small CLI ships with 80 to 200 MB of node_modules. A typical Next.js app sits between 600 MB and 1.5 GB. A React Native app with Expo pushes past 3 GB. Multiply by the forks, prototypes, take-home tests, and abandoned hackathon projects on a developer Mac and the cumulative footprint passes the size of macOS itself.
The other half is that nobody cleans them up. There is no npm uninstall-everywhere and Finder has no global view of node_modules density. The folders sit inside project directories next to source and .git, so a casual du -sh ~/code makes the source look heavy when it almost never is.
How do I find every node_modules folder on my Mac?
There is one canonical command. It runs in seconds even on a Mac with thousands of project folders because of the -prune flag.
# List every top-level node_modules under your home folder.
# -prune stops find from descending into nested node_modules,
# which would otherwise turn a 5-second job into a 5-minute one.
find ~ -type d -name node_modules -prune \
-not -path "*/.Trash/*" \
-not -path "*/Library/*" 2>/dev/null
The -not -path "*/Library/*" filter matters. Without it, find walks into ~/Library/Containers and ~/Library/Application Support, where Electron apps (VS Code, Cursor, Slack, Discord) ship their own internal node_modules. Those are application internals, not your projects, and deleting them breaks the host app.
The -prune is the trick most snippets get wrong. Without it, find recurses into each node_modules and tries to list every nested one inside, which on a workspace project means tens of thousands of directories. With -prune, find treats each top-level node_modules as a leaf and moves on.
How do I sort them by node_modules disk space?
The next step is mapping each folder to a size and sorting. du -sh reports a human-readable size per directory, and sort -h is the macOS sort flag that understands 1.2G is bigger than 980M.
# Find every project-level node_modules and sort by real disk size,
# largest at the bottom so the worst offenders are right above your cursor.
find ~ -type d -name node_modules -prune \
-not -path "*/.Trash/*" \
-not -path "*/Library/*" 2>/dev/null \
| xargs -I{} du -sh {} 2>/dev/null \
| sort -h
On a Mac that has been a JavaScript dev machine for two years, the tail is almost always a surprise. The 4 GB folder is rarely the project you are working on now. It is usually the React Native prototype from last spring, the Next.js take-home test from six months ago, or a fork you cloned to read once and forgot to delete.
What does a typical dev-Mac node_modules footprint look like?
The audit numbers below are from a real 512 GB MacBook with 2 years of side projects, anonymised. Yours will look different in the specifics but the shape is consistent.
| Folder type | Count | Avg size | Total | Last touched |
|---|---|---|---|---|
| Active client work | 6 | 1.2 GB | 7.2 GB | this week |
| Personal apps in progress | 4 | 1.8 GB | 7.2 GB | last 30 days |
| Open source forks (read-once) | 17 | 480 MB | 8.2 GB | 6+ months |
| Hackathon and take-home tests | 12 | 1.1 GB | 13.2 GB | 6+ months |
| Tutorials and course repos | 22 | 320 MB | 7.0 GB | 12+ months |
| Abandoned side projects | 31 | 980 MB | 30.4 GB | 12+ months |
| Total | 92 | 806 MB | 73.2 GB | mixed |
The bottom three rows are the reclaim. 50+ GB sitting in folders the user has not opened in over six months, every one of which has a lockfile committed and would rebuild on demand. That is the audit CleanMyDev surfaces row by row instead of asking you to trust a one-button sweep.
Is it safe to delete a node_modules folder?
Yes, with one rule: confirm the project has a lockfile. The lockfile is the contract; node_modules is the cache. A package-lock.json, pnpm-lock.yaml, yarn.lock, or bun.lockb lets the next install rebuild the exact same dependency tree bit-for-bit. Without a lockfile, the next install may resolve to newer versions of transitive dependencies, which sometimes matters and sometimes does not.
This is the same logic that makes deleting Xcode DerivedData a safe default, covered in the DerivedData safety audit. The artefact is reproducible, so removing it costs only the time it takes to rebuild.
For the deletion itself, prefer mv to Trash over rm -rf. APFS makes the move cheap because it is metadata-only, and the Trash is the rollback window if it turns out you deleted a node_modules that contained a locally linked dependency or a postinstall side effect you forgot about. The full reasoning is in Move to Trash vs rm -rf for a developer Mac.
# Move one project's node_modules to the Trash with a date stamp,
# so the Trash entry is identifiable and easy to restore.
PROJECT=~/code/old-side-project
mv "$PROJECT/node_modules" \
~/.Trash/node_modules-$(basename "$PROJECT")-$(date +%Y%m%d)
The pattern scales: a for loop over the find output moves dozens of dead node_modules to Trash in one pass. Wait a week, confirm nothing broke, then empty Trash.
A five-step audit playbook for node_modules disk space
This is the order I run when a Mac with two-plus years of JavaScript work hits a low-disk warning. Steps 1 through 3 do not touch a byte.
# 1. Inventory every top-level node_modules under home.
find ~ -type d -name node_modules -prune \
-not -path "*/.Trash/*" \
-not -path "*/Library/*" 2>/dev/null > /tmp/node_modules-list.txt
wc -l /tmp/node_modules-list.txt
# 2. Map each folder to a real disk size, sorted.
xargs -I{} du -sh {} 2>/dev/null < /tmp/node_modules-list.txt \
| sort -h > /tmp/node_modules-sizes.txt
tail -30 /tmp/node_modules-sizes.txt
# 3. For the worst offenders, check the last-modified date of
# the parent project. Anything older than 90 days is a candidate.
for nm in $(awk '{print $2}' /tmp/node_modules-sizes.txt | tail -30); do
project=$(dirname "$nm")
ls -lTd "$project" 2>/dev/null
done
# 4. Confirm each candidate has a lockfile committed.
for nm in $(awk '{print $2}' /tmp/node_modules-sizes.txt | tail -30); do
project=$(dirname "$nm")
ls "$project"/{package-lock.json,pnpm-lock.yaml,yarn.lock,bun.lockb} 2>/dev/null
done
# 5. Move the confirmed candidates to Trash, one at a time.
mv ~/code/old-project/node_modules \
~/.Trash/node_modules-old-project-$(date +%Y%m%d)
The candidate list is yours, not the script's. Steps 1 through 4 hand you the data, step 5 is the only mutation, and the mutation is reversible for at least the lifetime of the Trash.
How does node_modules cleanup compare across package managers?
The four JavaScript package managers store and rehydrate node_modules very differently, which changes both the reclaim size and the cost of running install again.
| Tool | node_modules shape |
Hardlinks store? | Reclaim per folder | Re-install cost |
|---|---|---|---|---|
| npm | Full deep tree | No | Large (1 to 4 GB) | Network + extraction |
| Yarn 1 | Full deep tree | No | Large (1 to 4 GB) | Network + extraction |
| Yarn Berry (PnP) | Often no folder | n/a | Small (.yarn/cache zips) |
Cache reuse |
| pnpm | Symlinked tree | Yes (to ~/Library/pnpm/store/v3) |
Mostly symlinks; store does the work | Hardlink rebuild |
| Bun | Hybrid | Optional | Medium (500 MB to 2 GB) | Very fast |
For npm and Yarn 1 projects, deleting node_modules is the cleanup. For pnpm projects, deleting node_modules reclaims a few hundred megabytes per project but the real win is then running pnpm store prune to drop blobs nothing references anymore. See the pnpm store cleanup playbook for the second-half pattern, and where the npm cache lives on Mac for the npm equivalent of the global store.
Why not just run a find cron that deletes old node_modules?
You can. The reason most experienced devs do not is the judgement call ("is this project really dead?") is hard to encode in a one-liner. A 90-day filter catches almost all the right folders but also catches the seasonal client project you only touch in Q4 and the take-home test you might re-open if the company calls back.
A safer scheduled job lists candidates for review rather than deleting in place. Safer still: surface the audit in a UI with path, size, last-modified date, lockfile presence, and a risk label per row, then route ticked rows to Trash with a week-long rollback. That is the review-first principle behind why CleanMyDev shows receipts.
Closing: receipts before deletion, even for node_modules
A find and a du are enough to recover most of the node_modules disk space on a developer Mac. The friction is not the commands, it is the judgement: which projects are dead, which have lockfiles, which one had that one local link: dependency you forgot about. If you want that judgement made out in the open with per-row metadata instead of inside a black-box cleaner, CleanMyDev is the $9.99 lifetime app that gives you the receipt before it deletes anything. Path, size, last-modified date, owning package manager, risk label, Move to Trash by default, no admin password, no subscription.