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:
change: src/index.js
change: src/index.js <-- Same event fired twice
change: src/index.js <-- And againOr the watcher crashes:
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: trueis 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
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:
# Cannot set sysctl in Dockerfile - must be set on the host
# Or run with --sysctl fs.inotify.max_user_watches=524288Step 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
chokidarfor all file watching in cross-platform projects - Set
fs.inotify.max_user_watchesto 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: trueas 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