JavaScript toolchain caches and Node versions
The js category (aliases: node, npm) covers everything the JavaScript and React Native toolchain keeps outside your projects: package-manager caches and stores, Node versions installed by version managers, Metro and Jest caches in the temp directory, Expo and EAS downloads, and browsers downloaded for end-to-end tests.
Almost everything here is regenerable. What you pay after cleaning is a re-download, or a slower first run. Each project's own node_modules is a project artifact, covered in React Native projects.
Quick start
Section titled “Quick start”lu-cleaner scan -c js # what takes space; nothing is touchedlu-cleaner clean -c js # picker: nothing selected, press a for the recommended itemslu-cleaner clean --yes --smart -c js --dry-run # the recommended plan, for scriptsA typical scan (sizes are illustrative):
JS toolchain 17.1 GB · 12 items · ★ 12.7 GB recommended NAME SIZE AGE RISK RECO Yarn Berry global cache 6.10 GB 4d safe ★ pnpm store v10 3.80 GB 2d moderate pnpm store v3 2.90 GB 7mo moderate ★ Jest cache 2.20 GB now safe ★ npm cache (_cacache) 1.30 GB now safe ★ node v22.12.0 (fnm) 190 MB 3w moderate ! pinned by 1 project file(s): ~/dev/shop-app/.nvmrc node v20.19.5 (nvm) 180 MB 2d caution ! default version: nvm default (20) node v18.20.4 (nvm) 172 MB 9mo moderate node v16.20.2 (nvm) 165 MB 1y moderate ★ npx cache · @playwright/mcp 48.0 MB 1d moderate ! used by 1 running process(es) (MCP server?) — deleting it breaks them until restarted fnm stale shell links (4210) 1.30 MB 2d safe ★ pnpm store prune ? 2d safe ★
★ marks what smart select picks; an item with a ! warning is never recommended. node v18.20.4 has no ★ although nothing references it: it is the newest unreferenced nvm version, which keep_latest keeps out of smart select (see below). The size of pnpm store prune is ? because what it frees is only known once it runs.
Package managers
Section titled “Package managers”| Item | Location | Kind (-k) |
Risk |
|---|---|---|---|
| npm cache | ~/.npm/_cacache |
js-npm-cache |
safe |
| npm debug logs | ~/.npm/_logs |
js-npm-logs |
safe |
| npx installs, one item per package | ~/.npm/_npx/<hash> |
npx-package |
moderate |
Binaries downloaded by postinstall scripts (@sentry/cli, sharp's libvips, prebuild-install) |
~/.npm/sentry-cli, ~/.npm/_libvips, ~/.npm/_prebuilds… |
js-npm-binary-downloads |
safe |
npx installs each package it runs into its own _npx/<hash> folder. lu-cleaner names these items after the package (npx cache · @playwright/mcp) because they are often MCP servers started by AI tools. An install used by a running process gets the warning used by N running process(es) (MCP server?) — deleting it breaks them until restarted; one unused for 14 days is recommended. The next npx call reinstalls it.
npx installs under a custom cache location ($npm_config_cache) are found too.
| Item | Location | Kind (-k) |
Risk |
|---|---|---|---|
| Yarn v1 cache | ~/Library/Caches/Yarn/v* |
js-yarn-classic-cache |
safe |
| Yarn v1 run temp dirs (older than a day) | $TMPDIR/yarn--* |
js-yarn-tmp |
safe |
| Yarn Berry global cache | ~/.yarn/berry/cache |
yarn-berry-cache |
safe, or caution with PnP projects |
| Yarn Berry registry metadata | ~/.yarn/berry/metadata |
yarn-berry-metadata |
safe |
| Yarn Berry hardlink store | ~/.yarn/berry/store (and legacy index) |
yarn-berry-store |
moderate |
The Yarn Berry (v2+) global folder is found through $YARN_GLOBAL_FOLDER, then globalFolder in ~/.yarnrc.yml, then the default ~/.yarn/berry.
- Metadata holds npm registry documents cached by Yarn.
yarn cache cleandoes not remove it, so it only ever grows. - Hardlink store is the content store of
nmMode: hardlinks-global. Files still hardlinked into a project'snode_modulessurvive deletion, so the space freed can be much smaller than the size shown. When that is the case, the size column shows a*and--jsonreports the real gain asreclaim. - Plug'n'Play caveat. Projects using PnP (a
.pnp.cjsfile instead ofnode_modules) read their dependencies straight from the zip files of the global cache at runtime: for them, the cache is the install. When lu-cleaner finds such a project in your roots and worktrees (searched down tomax_depth, like the artifacts scanner), the global cache becomes caution with the warning used at runtime by N Plug'n'Play project(s): they break untilyarn install(needs network), and it is never recommended. When the search could not look everywhere (too many folders, unreadable or excluded ones, a root equal to your home folder), the cache is moderate instead of safe.
pnpm keeps one content-addressable store per store format: v3 for pnpm 7 to 9, v10, v11… in ~/Library/pnpm/store by default (also $PNPM_HOME/store, ~/.local/share/pnpm/store, ~/.pnpm-store). lu-cleaner lists each store as a moderate item and asks pnpm store path which one is current.
For the current store you get two different options:
| Item | Method | Risk | What it does |
|---|---|---|---|
pnpm store prune |
runs pnpm store prune |
safe, recommended | removes only the packages no project references any more |
pnpm store v10 |
deletes the folder | moderate | removes the whole store; the next pnpm install re-downloads everything it needs |
Prefer prune for the store you use. Deleting a whole store makes sense for a format pnpm no longer uses, such as the v3 store left behind after upgrading to pnpm 10: it is recommended once unused for longer than stale_after. What prune frees is unknown until it runs, so its size shows as ? and it adds nothing to the totals; the details give the size of the whole store, the upper bound. The prune item only appears when the pnpm command works. If you select both, the store deletion wins and prune is dropped from the plan.
Because installed node_modules share files with the store (APFS clones or hardlinks), deleting a store can free less than its size. lu-cleaner measures this: see Disk space not freed.
Global virtual store. With enableGlobalVirtualStore (pnpm 10.12+), the node_modules of your projects are symlinks into the store's links/ folder. Deleting that store breaks them until the next pnpm install, so it becomes caution with the warning global virtual store used by N project(s): their node_modules break until pnpm install, and it is never recommended. Prune stays safe: it keeps what registered projects use. When a registered project cannot be reached (unmounted volume, no permission), prune is not recommended either: pnpm would treat that project as gone and drop its packages.
The pnpm metadata cache (~/Library/Caches/pnpm, kind js-pnpm-metadata-cache) is a separate safe item.
Bun and Corepack
Section titled “Bun and Corepack”| Item | Location | Kind (-k) |
Risk |
|---|---|---|---|
| Bun install cache | ~/.bun/install/cache |
js-bun-install-cache |
moderate |
| Bun transpiler cache | ~/Library/Caches/bun |
js-bun-transpiler-cache |
safe |
bunx temporary installs (older than a day) |
$TMPDIR/bunx-* |
js-bunx-tmp |
safe |
| Yarn and pnpm versions downloaded by Corepack | ~/.cache/node/corepack/* |
js-corepack-cache |
safe |
Bun clones files into node_modules on APFS, so deleting its install cache can free much less than its size. lu-cleaner measures the clones: the size then shows a *, and --json reports the real gain as reclaim. Corepack's lastKnownGood.json is kept.
Node versions
Section titled “Node versions”Where they are found
Section titled “Where they are found”| Manager | Versions folder |
|---|---|
| nvm | ~/.nvm/versions/node/ (or $NVM_DIR) |
| fnm | ~/.local/share/fnm/node-versions/, ~/Library/Application Support/fnm/, ~/.fnm/ (or $FNM_DIR) |
| mise | ~/.local/share/mise/installs/node/ (or $MISE_DATA_DIR) |
| asdf | ~/.asdf/installs/nodejs/ (or $ASDF_DATA_DIR) |
| Volta | ~/.volta/tools/image/node/ (or $VOLTA_HOME) |
Each installed version is one item, for example node v18.20.4 (nvm). The same version installed by two managers appears twice, and the details say where else it is installed.
What is kept
Section titled “What is kept”A Node version is only worth deleting when nothing uses it. lu-cleaner checks every reason to keep it, in this order:
| Reason | Risk | Effect |
|---|---|---|
A running process uses it (command line, or the node binary it executes) |
caution | cannot be selected |
It is the node on your PATH |
caution | warning this is the node on your PATH |
It is a default: nvm's default alias, fnm's default alias, mise's global config, ~/.tool-versions (mise and asdf), Volta's default |
caution | warning default version: … |
A project pins it in .nvmrc, .node-version or .tool-versions |
moderate | warning pinned by N project file(s): … |
| Another alias points to it (nvm or fnm) | moderate | warning referenced by … |
| Global npm packages are installed only in this version | moderate | warning global packages installed only here: … |
None of the above, but it is the newest such version of its manager (keep_latest of them, 1 by default) |
moderate | not recommended |
| None of the above | moderate | recommended |
Why so strict: deleting the default or the node on your PATH breaks every new shell, and deleting a pinned version breaks nvm use in that project. Global packages (npm install -g) live inside the version folder and disappear with it, so a version holding CLIs found nowhere else is flagged even when nothing else references it. The newest unreferenced version of each manager is kept out of smart select too: a version you just installed and switch to with nvm use in shells that are closed right now references nothing on disk, yet it is probably the one you are moving to.
Age does not matter here: an older unreferenced version is recommended even if you used it last week, since a reinstall takes seconds. Add --older-than (for example --older-than 90d) to be more conservative in scripts. If lu-cleaner cannot read the process list (ps), it cannot rule out that a version is running, and no version is recommended.
How pins are matched
Section titled “How pins are matched”Pins are read from your project roots and worktree roots, three levels deep, plus ~/.nvmrc and ~/.node-version. A pin keeps the version the manager would pick for it:
| Pin | Keeps |
|---|---|
20.19.5 |
that version; if it is not installed, the newest installed 20.x |
20 or 20.19 |
the newest installed version matching that prefix |
^20.11.0, >=20, ~20.11 |
the newest installed 20.x (ranges are reduced to their major version) |
lts/iron, lts/jod |
the newest installed version of that LTS line (20, 22…) |
lts/* |
the newest installed LTS (even major) version |
node, latest, stable |
the newest installed version |
system |
nothing |
nvm alias chains are followed (default → lts/* → lts/jod → v22.12.0), so the version your shell really uses is the one kept.
What deleting a version costs
Section titled “What deleting a version costs”The item note gives the reinstall command, such as nvm install 18.20.4, fnm install 18.20.4, mise install node@18.20.4, asdf install nodejs 18.20.4 or volta install node@18.20.4. The download is about 50 MB, but global npm packages installed under that version are lost. mise alias symlinks pointing at the version (20, lts…) are removed with it.
For fnm, the last-used date (the age column, and what --older-than compares against) also comes from the shell links described below, so a version you switch to often does not look old.
fnm shell links
Section titled “fnm shell links”fnm creates a symlink named <pid>_<timestamp> in a fnm_multishells folder (for example ~/.local/state/fnm_multishells or ~/Library/Caches/fnm_multishells) each time a shell runs fnm env, and never removes them. Thousands pile up on a machine where AI tools open shells all day. They take almost no space; the gain is inodes and a much smaller directory.
The <pid> is that of the short-lived fnm env process, not of the shell, so "this pid is dead" proves nothing: an open terminal, or an app that captured a shell environment (Claude, Cursor, Codex…), still points to its link through PATH. lu-cleaner only treats a link as stale when:
- it was created before the last boot, since every process has restarted since; or
- it is older than a day, no running process references it (command line or environment), and no running process started around the time it was created.
Your own shell's link is always kept. The item, fnm stale shell links (N), is safe and recommended; only the symlinks are removed, never Node itself. Its size is an estimate, since symlinks have no data blocks. It is skipped when the process list is unavailable, and at clean time a link a process is still executing through is refused with in use: process … runs a program or library from ….
Interrupted npm install -g leftovers
Section titled “Interrupted npm install -g leftovers”When an npm install -g is interrupted, which happens often with AI CLIs updating themselves, npm leaves a temporary folder such as .some-cli-a1B2c3D4 in the global lib/node_modules, sometimes hundreds of MB. npm never uses it again.
lu-cleaner looks for these in every managed Node version and in user-level npm prefixes ($npm_config_prefix, ~/.local, ~/.npm-global, ~/.npm-packages). Leftovers older than 24 hours and not used by any running process are safe and recommended (kind npm-global-leftover).
Metro, Jest and other temp caches
Section titled “Metro, Jest and other temp caches”These caches live in your per-user temp directory ($TMPDIR, under /var/folders/…). Several of them grow with every git worktree, because they are keyed by project path.
| Item | Location | Kind (-k) |
Notes |
|---|---|---|---|
| Metro bundler cache | $TMPDIR/metro-cache, metro-bundler-cache*, react-native-packager-cache-* |
js-metro-cache |
shared by every React Native / Expo project; like --reset-cache, the next bundle is slower |
| Metro / Haste file maps | $TMPDIR/metro-file-map-*, haste-map-* |
js-metro-file-maps |
one per project root: every worktree adds one |
| Jest cache | $TMPDIR/jest_* |
js-jest-cache |
one set per project path (about 100 MB per worktree) |
| Vitest module-runner dirs | 21-character folders in $TMPDIR holding only client/ and ssr/ |
vitest-tmp |
recreated on each run, never removed |
| React Native codegen dirs | $TMPDIR/<lib>XXXXXX/out with codegen output |
rn-codegen-tmp |
created by each pod install or Gradle run, never removed |
| Node module compile cache | $TMPDIR/node-compile-cache, v8-compile-cache-* |
js-node-compile-cache |
V8 code cache of CLIs (npm, AI CLIs…), one per Node version |
| tsx transform cache | $TMPDIR/tsx-* |
js-tsx-cache |
rebuilt automatically |
All are safe. Vitest and codegen folders have generic names, so lu-cleaner checks their content before proposing them, and ignores those modified in the last 2 hours in case a run is in progress.
React Native, Expo and EAS
Section titled “React Native, Expo and EAS”| Item | Location | Kind (-k) |
Risk |
|---|---|---|---|
| React Native prebuilt tarballs (core, deps, Hermes) | ~/Library/Caches/ReactNative |
js-react-native-prebuilt |
safe: the next pod install downloads them again |
| DotSlash cache (React Native DevTools) | ~/Library/Caches/dotslash |
js-dotslash-cache |
safe |
| Flipper leftovers | ~/.flipper |
js-flipper-leftovers |
safe, always recommended: Flipper was removed from React Native 0.74+ |
| Expo CLI caches | ~/.expo/cache, native-modules-cache, schema-cache, versions-cache, template-cache |
js-expo-cli-caches |
safe |
| Expo Go iOS simulator builds | ~/.expo/ios-simulator-app-cache/Expo-Go-* |
expo-go-ios |
see below |
| Expo Go Android APKs | ~/.expo/android-apk-cache |
js-expo-go-android |
safe |
| EAS CLI upload archives (older than a day) | $TMPDIR/eas-cli-nodejs |
js-eas-cli-tmp |
safe; a recent one may belong to an upload in progress |
| EAS CLI cache | ~/Library/Caches/eas-cli |
js-eas-cli-cache |
safe |
eas build --local work dirs (older than 2 hours) |
$TMPDIR/eas-build-local-nodejs |
js-eas-local-build |
safe; do not clean during a local build |
expo start --ios caches one Expo Go build (about 250 MB) per version. Expo Go versions follow the SDK number (52.0.3 is SDK 52), so lu-cleaner groups them by SDK: a build superseded by a newer one of the same SDK is safe and recommended; the newest build of each SDK is moderate and carries a warning when a project in your roots uses that Expo SDK.
Your Expo login (~/.expo/state.json) is on the safety guard's protected list and is never touched.
Test browsers
Section titled “Test browsers”| Item | Location | Kind (-k) |
Risk |
|---|---|---|---|
| Playwright browsers, one item per revision | ~/Library/Caches/ms-playwright/<browser>-<revision> |
playwright-browser |
moderate |
| Playwright temp profiles, traces and transform cache (older than a day) | $TMPDIR/playwright-*, $TMPDIR/playwright_*dev_profile-* |
js-playwright-tmp |
safe |
Puppeteer Chrome, headless shell and Firefox (newest kept, or the keep_latest newest) |
~/.cache/puppeteer/… |
js-puppeteer-chrome, js-puppeteer-headless-shell, js-puppeteer-firefox |
moderate |
Cypress app versions (newest kept, or the keep_latest newest) |
~/Library/Caches/Cypress/* |
js-cypress-binaries |
moderate |
| Selenium Manager drivers | ~/.cache/selenium |
js-selenium-drivers |
safe |
Playwright records which installs need which browser revisions in ms-playwright/.links. lu-cleaner reads those links and each install's browsers.json: a revision no installed Playwright needs any more is recommended, and one still needed carries needed by N installed Playwright(s). When the links are not available, older revisions are recommended and the newest revision of each browser is only recommended once stale. A browser that is running is never recommended. $PLAYWRIGHT_BROWSERS_PATH is honoured.
Watchman
Section titled “Watchman”Watchman keeps watching directories after they are deleted, typically the worktrees of finished agent tasks. When watchman watch-list shows watches on folders that verifiably no longer exist, lu-cleaner proposes to remove those watches and recommends doing so. It runs watchman watch-del <root> once per stale watch, never watch-del-all: the watches of a running Metro or jest --watch are left untouched, so they keep seeing file changes.
This frees memory and CPU, not disk space. Right before running, lu-cleaner lists the watches again and skips the item if a folder exists again or a watch is already gone. It queries Watchman with --no-spawn, so neither a scan nor a clean ever starts the Watchman server.
Other JS tool caches
Section titled “Other JS tool caches”The catalog also covers node-gyp headers, nvm, Volta, asdf and mise download caches, Electron and electron-builder downloads, the TypeScript type-acquisition cache, Deno, Turborepo, esbuild, Next.js SWC binaries, Prisma engines, Convex local backends and giget templates. All are safe: the tool downloads or rebuilds them when needed.
Recommended workflow
Section titled “Recommended workflow”-
Stop Metro, Jest watchers and test runs, so their caches are not in use.
-
Make sure your project roots include every project and worktree folder, so Node pins and Yarn PnP projects are found:
Terminal window lu-cleaner config show -
Review what is there:
Terminal window lu-cleaner scan -c js --all -
Open the picker, press a to select the recommended items (or pick with space), check the selection (especially Node versions and pnpm stores), then press d:
Terminal window lu-cleaner clean -c js
Scripting
Section titled “Scripting”# The plan smart select would run; nothing is deletedlu-cleaner clean --yes --smart -c js --dry-run
# Only Metro, Haste and Jest cacheslu-cleaner clean --yes -k js-metro-cache,js-metro-file-maps,js-jest-cache
# Unreferenced Node versions onlylu-cleaner clean --yes --smart -k node-version --dry-runThe provider id js selects every item of the Node-versions scanner (Node versions, fnm links, npm -g leftovers, npx, pnpm, Yarn Berry, Expo Go, Playwright, Vitest and codegen temp dirs, Watchman); the js-* kinds come from the catalog. Item metadata such as kept_because, globals_only_here, pnp_projects or used_by is available in lu-cleaner scan --json, see JSON output.