Why node_modules takes so much space.
Every JavaScript project keeps its dependencies in its own node_modules folder, next to its package.json. That includes the dependencies of your dependencies, so a framework app with a dozen direct packages can easily hold hundreds of them. On the Mac this guide was written on, a single Next.js site's node_modules was about 460 MB.
npm unpacks its own copy of each package into each project. Ten old projects mean ten copies, and the folder stays after you stop working on a project. Nothing cleans it up for you. The good news: everything in it can be installed again from the project's lockfile.
How do I find every node_modules folder?
Use find on the folder where you keep your code, not on your whole disk. Replace ~/code with your own projects folder:
find ~/code -name node_modules -type d -pruneThe -prune matters. It tells find not to go inside a folder it has matched, so you get one line per project instead of thousands of nested node_modules folders inside the first one. It also makes the search much faster, because the biggest folders are the ones it skips.
How big are they?
Add du to see each folder's size, biggest first:
find ~/code -name node_modules -type d -prune -exec du -sh {} + | sort -rhAnd the total for all of them:
find ~/code -name node_modules -type d -prune -print0 | xargs -0 du -sch | tail -1Sizes alone don't tell you which projects are old. This loop prints each folder's size, the date of the project's last Git commit, and its path:
find ~/code -name node_modules -type d -prune | while IFS= read -r d; do
printf '%s\t%s\t%s\n' "$(du -sh "$d" | cut -f1)" \
"$(git -C "$d/.." log -1 --format=%cr 2>/dev/null || echo 'no git')" "$d"
doneA project whose last commit was a year ago is a good candidate. One you committed to this morning is not.
Is it safe to delete node_modules?
In a project, yes. node_modules holds no source code of yours. It is rebuilt from package.json and the lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock). What you give up:
- Time and network. The next install downloads whatever isn't in your package manager's cache, and packages with install scripts build again.
- Exact versions, if there is no lockfile. Without one, the next install resolves versions fresh and may pick newer ones. Check that the lockfile is there, and committed, before you delete.
- Local edits. If you patched a file inside
node_modulesby hand, the patch is gone. Tools likepatch-packagekeep patches outside the folder for this reason.
Outside your projects, be careful. Globally installed command-line tools live in a node_modules folder too, such as /opt/homebrew/lib/node_modules with Homebrew's Node, and some apps ship one inside their bundle. Deleting those breaks the tool or the app. That is why the search above starts in your code folder and not in ~ or /.
Delete node_modules and reinstall.
For one project, delete the folder from the project's root and install again from the lockfile:
rm -rf node_modules
npm cinpm ci installs exactly what package-lock.json says and refuses to run if the lockfile and package.json disagree. It also removes an existing node_modules on its own, so the rm is only needed if you want the space back now and the install later. The equivalents for the other package managers:
pnpm install --frozen-lockfile
bun install --frozen-lockfilePlain npm install or pnpm install also work. They may update the lockfile if it no longer matches package.json.
How do I delete all node_modules folders at once?
The short version deletes every node_modules that find lists, with no questions:
find ~/code -name node_modules -type d -prune -exec rm -rf {} +Run it only after you ran the same command without the -exec rm -rf {} + part and read the list. A few rules keep it safe:
- Scope the root. Never point it at
/or at your whole home folder. You would remove the folders of global tools and of apps that keep one, along with your projects'. - Keep
-prune. Without it,findwalks into folders thatrmis about to remove and prints errors for paths that no longer exist. - Don't swap in
-delete. On macOS,-deleteimplies a depth-first walk, which turns-pruneoff, and it only removes empty folders. Everynode_modulesis full, so it fails on each one. - Nothing goes to the Trash.
rm -rfis permanent. If you got the root wrong, there is no undo.
A gentler way is to save the list, remove the lines you want to keep, and delete only what is left. The loop deletes a line only if it really ends in node_modules, so a stray line can't take something else with it:
find ~/code -name node_modules -type d -prune > ~/node_modules.txt
open -e ~/node_modules.txt # remove the projects you still work on, then save
while IFS= read -r d; do
[ "$(basename "$d")" = node_modules ] && rm -rf "$d"
done < ~/node_modules.txtCan I use npkill instead?
npkill is an open-source tool on the npm registry that lists node_modules folders with their sizes in Terminal and deletes the one you select. You can run it without installing it:
npx npkill -d ~/codeMove with the arrow keys and press Space or Delete to remove the selected folder. -d sets the folder to search, --sort size puts the biggest first, and --dry-run only pretends to delete. It marks folders that look like they belong to an installed app with a warning, for the reasons above. Like rm -rf, it deletes for good.
Why deleting a pnpm or Bun node_modules frees less.
pnpm and Bun don't keep a full copy per project. They keep one copy of each package in a shared store, and fill node_modules from it. On macOS, both use APFS clones when they can: pnpm's default import method tries cloning before hard links on macOS, and Bun uses clonefile by default. A clone or a hard link shares its disk blocks with the store.
du can't see that. It counts a cloned folder at full size, so a pnpm project can look as big as an npm one while deleting its node_modules frees little, because the store still holds the same bytes. The space comes back when the store lets go of packages no project uses any more. For pnpm that is pnpm store prune, covered in the package manager caches guide, along with npm's and Bun's caches.
The other folders projects leave behind.
node_modules is rarely alone. These are build output or environments, and each comes back with one command:
| Folder | What it is | How it comes back |
|---|---|---|
.next | Next.js build output and its build cache | next build or next dev |
.turbo | Turborepo's local task cache | turbo run, with cache misses the first time |
target/ next to Cargo.toml | Rust build output, for every profile and target you built | cargo build; cargo clean removes it for you |
.venv or venv | A Python virtual environment | python3 -m venv .venv and reinstall, or uv sync in a uv project |
The same find pattern works for any of them. For Rust, cargo clean --dry-run shows what it would delete before you run it for real. Before you delete a virtual environment, make sure its packages are written down in requirements.txt, pyproject.toml or a lockfile, so you can install the same ones again.
Gradle projects keep build/ and .gradle/ folders of the same kind; the Gradle guide covers those and Gradle's global caches.
How Specula handles it.
Specula's full scan finds project artifacts: node_modules next to a package.json, Rust target/ next to a Cargo.toml, Gradle build/ and .gradle/, SwiftPM .build, Python virtual environments, CocoaPods Pods, .next, .turbo and Xcode build folders. Each one is shown with when its project was last touched.
“Last touched” means the project folder or anything directly in it changed, .git included, so a commit or a checkout counts, or its build output did. In the Clean Up plan, artifacts of projects nobody touched for 14 days are checked by default. Those in projects used more recently are offered separately and stay unchecked until you check them, because their next build or install takes longer.
Global installs and tool caches, such as ~/.bun, ~/.npm or anything in ~/Library, are never treated as project artifacts. Nothing is deleted without a confirmation, and you choose between the Trash and deleting permanently.