Introduction

Node.js built-in fs.watch() with the recursive: true option has inconsistent behavior across platforms. On Linux, the recursive option is often ignored entirely because the underlying inotify API does not support recursive watching. On Windows, it works but has limitations with network drives and deeply nested directories. On macOS, it works well with FSEvents but can produce duplicate events. This cross-platform inconsistency causes file watching code that works perfectly on a developer's Mac to silently miss file changes on a Linux CI server or Windows production machine.

Symptoms

Recursive watch does not catch changes in subdirectories:

javascript fs.watch('/path/to/project', { recursive: true }, (eventType, filename) => { console.log(${eventType}: ${filename}`); });

// Works on macOS - catches all changes // On Linux - only catches changes in the root directory, NOT subdirectories ```

Or duplicate events:

bash
change: src/index.js
change: src/index.js  <-- Same event fired twice
change: src/index.js  <-- And again

Or the watcher crashes:

bash
Error: ENOSPC: System limit for number of file watchers reached
    at FSWatcher.<computed> (node:internal/fs/watchers:244:19)

Common Causes

  • Linux inotify does not support recursive watching: recursive: true is silently ignored
  • inotify user watch limit too low: Default limit of 8192 watches is exceeded by large projects
  • Duplicate events on macOS: FSEvents reports multiple events for the same change
  • Network drives on Windows: SMB/CIFS shares do not support file change notifications
  • Symlinks not followed: fs.watch does not follow symlinks by default
  • Watcher not closed: File watchers accumulate, exhausting system resources

Step-by-Step Fix

Step 1: Use chokidar for cross-platform watching

bash
npm install chokidar

```javascript const chokidar = require('chokidar');

const watcher = chokidar.watch('/path/to/project', { ignored: /(^|[\/\])\../, // Ignore dotfiles persistent: true, ignoreInitial: true, // Do not emit 'add' for existing files followSymlinks: true, cwd: '.', disableGlobbing: false,

// Use polling as fallback (needed for network drives) usePolling: process.env.USE_POLLING === 'true', interval: 100, });

watcher .on('add', (path) => console.log(File added: ${path})) .on('change', (path) => console.log(File changed: ${path})) .on('unlink', (path) => console.log(File removed: ${path})) .on('addDir', (path) => console.log(Directory added: ${path})) .on('unlinkDir', (path) => console.log(Directory removed: ${path})) .on('error', (error) => console.error(Watcher error: ${error})) .on('ready', () => console.log('Initial scan complete, watching for changes'));

// Graceful shutdown process.on('SIGTERM', () => { watcher.close().then(() => console.log('Watcher closed')); }); ```

Step 2: Increase inotify watch limit on Linux

```bash # Check current limit cat /proc/sys/fs/inotify/max_user_watches # Default: 8192

# Increase limit echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf sudo sysctl -p

# Verify cat /proc/sys/fs/inotify/max_user_watches # Output: 524288 ```

In Docker:

dockerfile
# Cannot set sysctl in Dockerfile - must be set on the host
# Or run with --sysctl fs.inotify.max_user_watches=524288

Step 3: Handle duplicate events with debounce

```javascript const path = require('path');

function createDedupedWatcher(watchPath, onChange, debounceMs = 100) { const pending = new Map();

return fs.watch(watchPath, { recursive: true }, (eventType, filename) => { if (!filename) return;

const fullPath = path.join(watchPath, filename); const key = ${eventType}:${fullPath};

// Debounce: only call onChange once per file per debounceMs if (pending.has(key)) { clearTimeout(pending.get(key)); }

const timeout = setTimeout(() => { pending.delete(key); onChange(eventType, fullPath); }, debounceMs);

pending.set(key, timeout); }); }

// Usage const watcher = createDedupedWatcher('./src', (eventType, filename) => { console.log(${eventType}: ${filename}); // Rebuild, reload, etc. }); ```

Prevention

  • Use chokidar for all file watching in cross-platform projects
  • Set fs.inotify.max_user_watches to at least 524288 on Linux development machines
  • Debounce file change events to handle duplicate notifications
  • Always close file watchers with watcher.close() on process exit
  • Use usePolling: true as a fallback for network drives and Docker volumes
  • Add a startup check that verifies the inotify watch limit on Linux
  • Monitor the number of active file watchers in long-running processes