#1452·chokidar

v5: many concurrent watchers cause child_process.fork() EBADF on macOS (regression from v3)

Author: PrainsCreated Mar 12, 2026Updated Apr 30, 2026

When chokidar v5 watches a large project directory (depth ≥ 4, or roughly >10,000 files/dirs), any subsequent call to child_process.fork() fails with EBADF. This is a regression from v3 which used fsevents and did not exhibit this problem.

Environment

  • OS: macOS Darwin 25.3.0 (macOS 16 beta), Apple Silicon
  • Node.js: v24.13.0
  • chokidar: 5.0.0
  • Reproduced in: Nuxt 4 dev server (spawn EBADF in ForkPool.createFork)

Minimal reproduction

  import { fork } from 'child_process';
  import chokidar from 'chokidar';

  const forkTest = () => new Promise(resolve => {
    try {
      const p = fork('/dev/null', [], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] });
      p.on('error', e => resolve('ERROR: ' + e.code));
      p.on('exit', () => { p.kill(); resolve('OK'); });
    } catch (e) {
      resolve('THREW: ' + e.code);
    }
  });

  // Works fine before watching
  console.log(await forkTest()); // → OK

  // Watch a large project directory
  const watcher = chokidar.watch('/path/to/large/project', {
    ignored: /(node_modules|\.git)/,
    ignoreInitial: true,
    depth: Infinity
  });
  await new Promise(r => watcher.on('ready', r));

  // fork() now fails
  console.log(await forkTest()); // → THREW: EBADF

  // Closing the watcher restores normal behavior
  await watcher.close();
  console.log(await forkTest()); // → OK

Root cause analysis

chokidar v5 dropped fsevents and switched to individual node:fs.watch() calls — one per file and one per directory. On macOS, each fs.watch() call opens a file descriptor via kqueue + EVFILT_VNODE and keeps it open for the lifetime of the watch.

For a typical medium-to-large project (e.g. a Nuxt app with Tauri integration), watching the full source tree creates 10,000–40,000 concurrent fs.watch() handles. At approximately >10,500 concurrent handles, child_process.fork() begins failing with EBADF.

We isolated the threshold experimentally:


  import { fork } from 'child_process';
  import { watch } from 'node:fs';
  import { readdir } from 'node:fs/promises';
  import { join } from 'node:path';

  const forkTest = () => new Promise(resolve => {
    try {
      const p = fork('/dev/null', [], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] });
      p.on('error', e => resolve('ERR:' + e.code));
      p.on('exit', () => { p.kill(); resolve('OK'); });
    } catch (e) { resolve('THREW:' + e.code); }
  });

  // Collect all files+dirs recursively
  async function getAllPaths(base, depth = 0) {
    if (depth > 5) return [];
    let paths = [base];
    const entries = await readdir(base, { withFileTypes: true });
    for (const e of entries) {
      if (e.name.startsWith('.') || e.name === 'node_modules') continue;
      const p = join(base, e.name);
      if (e.isDirectory()) paths.push(...await getAllPaths(p, depth + 1));
      else paths.push(p);
    }
    return paths;
  }

  const paths = await getAllPaths('/path/to/large/project');
  console.log('Total paths:', paths.length); // e.g. 11734

  const watchers = [];
  for (let i = 0; i < paths.length; i++) {
    try { watchers.push(watch(paths[i], () => {})); } catch (e) {}
    if (i % 500 === 0) {
      const result = await forkTest();
      console.log(`watchers=${i + 1}:`, result);
      if (result !== 'OK') { console.log('Threshold reached at', i + 1); break; }
    }
  }
  // Output: fails around 10,500

Why does this happen? On macOS, posix_spawn() (used internally by Node.js/libuv to create child processes) iterates over all open file descriptors to set up close-on-exec actions for the child. When the process has ~10,000+ open kqueue fds from fs.watch(), this operation fails with EBADF on macOS — a known macOS-specific limitation with large numbers of open file descriptors during posix_spawn().

Note: this affects fork() even with stdio: ['pipe', 'pipe', 'pipe', 'ipc'] — it is not a stdio inheritance issue.

Why chokidar v3 did not have this problem

chokidar v3 used the fsevents native module on macOS, which opens a single FSEventStreamRef for an entire directory tree. This uses Apple's FSEvents framework and does not accumulate per-file kqueue descriptors.

Confirming: a single fs.watch(path, { recursive: true }, ...) call also does not trigger the issue — it uses FSEvents internally and works correctly regardless of directory size.

Impact

Any application using chokidar v5 to watch a large project on macOS will silently break child_process.fork(). We observed this in:

  • Nuxt 4 dev server (ForkPool.createFork fails with EBADF after build)
  • Likely reproducible in any large Webpack/Vite/Next.js/Nest.js project on macOS

Possible fixes

  1. Re-add fsevents as an optional dependency on macOS (same as v3). Use it when available; fall back to node:fs.watch only when unavailable. This is the cleanest fix and restores v3 behavior on macOS.
  2. Use { recursive: true } in fs.watch() on macOS. A single recursive fs.watch() per top-level watched path uses FSEvents internally and avoids accumulating per-file kqueue handles. This would require refactoring depth tracking to be done in JS rather than at the OS level.
  3. Document the limitation and recommend that users configure ignored patterns to keep the total number of watched paths below ~10,000 on macOS.