#1474·chokidar

usePolling: silent 100% CPU busy-loop when a relatively-watched file's tree (incl. process CWD) is deleted — repros on v3/v4/v5

Author: bernardgutCreated Jul 17, 2026Updated Jul 17, 2026

Summary

When chokidar watches a single file by relative path with usePolling: true, and the watched file's directory tree — including the process's working directory — is deleted, the watcher enters a silent, unbounded, zero-delay retry loop that pins a CPU core (~100%) forever, saturating the main thread and the entire libuv threadpool with tens of thousands of fs operations per second.

Reproduced on v3.6.0, v4.0.3, and v5.0.0, on Node 24.15.0 and 26.0.0 (Linux, ext4).

Real-world impact: @stoplight/prism-cli (mock server) watches its OpenAPI spec exactly this way (relative path, CHOKIDAR_USEPOLLING=1, no error handler). One such server left running in a git worktree went from idle to a constant ~107% CPU the moment the worktree was deleted, and burned a full core silently for 22+ hours before being found.

Reproduction

repro.mjs:

javascript
import { watch } from 'chokidar';
const w = watch('sub/spec.yaml', { usePolling: true }); // relative path
w.on('all', (ev, p) => console.log(ev, p));
w.on('error', (e) => console.log('error', e?.code));
setInterval(() => {}, 60000); // keep process alive (stand-in for a server)
bash
mkdir -p /tmp/repro/wt/sub && echo hi > /tmp/repro/wt/sub/spec.yaml
cd /tmp/repro/wt
node /path/to/repro.mjs &   # (with chokidar installed next to repro.mjs)
sleep 2
rm -rf /tmp/repro/wt        # delete the tree INCLUDING the process CWD
# -> the node process is now pinned at ~100% CPU forever, with no output

Measured CPU of the watcher process over 3 s, ~4 s after the deletion:

chokidar watch path mode CPU
3.6.0 relative polling ~106%
3.6.0 absolute polling 0%
3.6.0 relative inotify (usePolling: false) 0%
4.0.3 relative polling ~97%
5.0.0 relative polling ~98%
5.0.0 (Node 24.15.0) relative polling ~108%

Thread profile of the spinning process: main thread ~100% plus all 4 default libuv threadpool workers busy; ~70k eventfd/pipe wakeups per second (≈35k async fs completions/sec). No output is ever produced after the single unlink event.

Mechanism (traced line-by-line on v3.6.0; the same logic survives verbatim in v5.0.0)

  1. Polling notices the deletion; _remove() runs the single-watched-file branch — "if the only watched file is removed, watch for its return" — and re-adds the watch (v5.0.0 index.js ~L721-723; v3.6.0 index.js ~L856-858).
  2. add()'s failure path walks up to the parent: when _addToNodeFs returns the failed path, the .then() immediately re-calls this.add(sp.dirname(item), sp.basename(_origAdd || item))with no delay (v5.0.0 index.js ~L371-378; v3.6.0 index.js ~L449-460).
  3. With a relative watch path the walk-up terminates at '.', which is a fixed point: path.dirname('.') === '.'. And at '.' the failure is persistent: stat('.') succeeds (Linux resolves . through the deleted-but-still-referenced CWD inode), but the follow-up realpath('.') throws ENOENT (it resolves through Node's cached, now-stale process.cwd() string). Failure → step 2 fires again with identical arguments → infinite zero-delay loop.
  4. The loop is silent: every error is ENOENT, which _handleError intentionally does not emit, so even a registered error listener hears nothing.

Each iteration costs one stat plus several lstats (the realpath component walk) on the threadpool, which matches the measured ~35k fs completions/sec.

Expected behavior

Retry at the polling interval (or give up and emit an error) — never a zero-delay loop. Two small fixes would each break the cycle:

  • In the add() walk-up, stop when sp.dirname(item) === item (fixed point at '.' or /), and/or
  • Delay the failure-path re-add by options.interval (or any backoff) instead of recursing synchronously in the .then().

Environment

  • chokidar 3.6.0 / 4.0.3 / 5.0.0
  • Node.js 26.0.0 (host) and 24.15.0 (container) — behavior identical
  • Linux 6.17 (Ubuntu), ext4