chokidar
Chokidar watches files and directories for changes and reports them as clean add, change, and unlink events instead of the raw, duplicated, and platform-inconsistent output of Node's fs.watch. It handles the mess for you: macOS events get real filenames, editors that write via temp-file rename produce one change event instead of unlink plus add, chunked writes can be held until the file stops growing, and recursion works everywhere with an optional depth limit. Built for the Brunch build tool in 2012, it became the watching layer under a huge share of dev servers and CLI tools.
The default answer for file watching in Node and still the most battle-tested set of platform workarounds available; v4 and v5 made it smaller and dependency-light. Plan around the two breaking cliffs (globs gone in v4, ESM-only in v5) before you upgrade rather than after.
Use it if
- You are building anything with a watch mode (dev server, test runner, build tool) and need events that mean what they say across macOS, Linux, and Windows
- You need to survive editor atomic writes and large chunked writes: the atomic and awaitWriteFinish options exist precisely for those cases
- You watch network drives or containers where inotify does not work; usePolling switches the backend without changing your code
- You want a small dependency: v4+ has exactly one dependency (readdirp) and no native compile step since fsevents was dropped
- You still pass glob patterns like '**/*.js' to watch(): glob support was removed in v4, so migrating from v3 means rewriting those into ignored functions or pre-resolved file lists
- You are stuck on CommonJS or Node below 20.19: v5 (Nov 2025) is ESM-only with a Node 20.19+ floor, so require('chokidar') pins you to v4 or v3
- You watch very large trees and care about every millisecond: @parcel/watcher does the watching in native code with lower overhead, at the cost of a compiled dependency
- Your needs are trivial and modern: on current Node, fs.watch with { recursive: true } works on macOS, Windows, and Linux, and may be enough for a simple reload script
Setup reality
npm install chokidar and you are done: one dependency, TypeScript types bundled, no postinstall or native builds since the fsevents days ended with v4. The friction is version and platform shaped. v5 is ESM-only and needs Node 20.19+, so CJS projects stay on v4. If you came from v3, every glob you passed to watch() or unwatch() silently means something else now and must become an ignored function. On Linux, big trees exhaust inotify watch limits (ENOSPC) until you raise fs.inotify.max_user_watches, and EMFILE errors on huge projects may need graceful-fs or polling.
Patterns
Watch a directory and log every eventwatch-directory
import chokidar from 'chokidar';
chokidar.watch('.').on('all', (event, path) => {
console.log(event, path);
});v5 is ESM-only; require('chokidar') throws in CommonJS, use v4 there. Watching '.' recurses fully, so scope the path or set depth to avoid watching node_modules.
Listen for add, change, and unlink separatelyspecific-events
import chokidar from 'chokidar';
const watcher = chokidar.watch('./src', { persistent: true });
watcher
.on('add', path => console.log(`added ${path}`))
.on('change', path => console.log(`changed ${path}`))
.on('unlink', path => console.log(`removed ${path}`))
.on('addDir', path => console.log(`dir added ${path}`))
.on('unlinkDir', path => console.log(`dir removed ${path}`))
.on('error', err => console.error('watcher error', err));Always attach an error handler; without one, an EMFILE or permission error crashes the process.
Watch only certain file types (globs are gone)filter-by-extension
import chokidar from 'chokidar';
// v3: chokidar.watch('**/*.js') <- no longer works
chokidar.watch('.', {
ignored: (path, stats) => stats?.isFile() && !path.endsWith('.js'),
});Since v4, watch() takes paths only. The ignored function is called twice per path, once without stats and once with, so guard with stats?.isFile() before rejecting.
Ignore node_modules and dotfilesignore-directories
import chokidar from 'chokidar';
chokidar.watch('./project', {
ignored: (path) => path.includes('node_modules') || /(^|[\/\\])\../.test(path),
ignoreInitial: true,
});ignoreInitial: true suppresses the flood of add events for files that already exist when the watcher starts; without it you get one add per existing file before ready.
Act only after the initial scan finisheswait-for-ready
import chokidar from 'chokidar';
const watcher = chokidar.watch('./data');
watcher.on('ready', () => {
console.log('initial scan done');
watcher.on('add', path => console.log('new file:', path));
});Attaching add handlers inside ready is the other way to skip startup noise if you still want the initial file list via getWatched().
Hold events until large files finish writingawait-write-finish
import chokidar from 'chokidar';
chokidar.watch('./uploads', {
awaitWriteFinish: {
stabilityThreshold: 2000,
pollInterval: 100,
},
}).on('add', path => processUpload(path));Without this, add fires when the file first appears, often before the writer is done. The threshold means every event is delayed at least that long, so keep it as low as your producers allow.
Use polling for network or virtual filesystemspolling-network-drives
import chokidar from 'chokidar';
chokidar.watch('/mnt/share', {
usePolling: true,
interval: 100,
binaryInterval: 300,
});fs.watch events usually do not propagate over NFS/SMB or some Docker mounts; polling works everywhere but costs CPU proportional to file count. CHOKIDAR_USEPOLLING=1 flips it via environment.
Add and remove watched paths at runtimeadd-unwatch-paths
import chokidar from 'chokidar';
const watcher = chokidar.watch('./a');
watcher.add(['./b', './c']);
await watcher.unwatch('./a');
console.log(watcher.getWatched());unwatch is async in recent versions; getWatched() returns an object keyed by directory with arrays of contained names, useful for debugging what is actually being watched.
Shut down a watcher cleanlyclose-watcher
import chokidar from 'chokidar';
const watcher = chokidar.watch('./src');
// later, e.g. on SIGINT:
await watcher.close();
console.log('watcher closed');close() is async; if you forget to await it in tests, file handles leak between cases and you get flaky cross-test events.
Get event paths relative to a base directoryrelative-paths-cwd
import chokidar from 'chokidar';
chokidar.watch('src', { cwd: 'src' })
.on('change', path => {
// path is like 'components/App.js', not 'src/components/App.js'
console.log(path);
});Without cwd, emitted paths mirror however you specified the watch path (relative or absolute), which regularly surprises people building path maps.
Use fs.Stats delivered with eventsstats-on-events
import chokidar from 'chokidar';
chokidar.watch('./logs', { alwaysStat: true })
.on('change', (path, stats) => {
if (stats) console.log(`${path} is now ${stats.size} bytes`);
});add, addDir, and change may include stats for free when the backend provides them; alwaysStat: true guarantees it at the cost of an extra stat call per event.
Limit recursion depthlimit-depth
import chokidar from 'chokidar';
chokidar.watch('./project', {
depth: 1, // watch project/ and one level of subdirs only
});Each watched directory consumes an OS watch handle; on Linux, deep trees hit fs.inotify.max_user_watches (ENOSPC) unless you raise the sysctl or bound depth.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @parcel/watcher | npm | You want native-code watching performance and snapshot support, and can accept a compiled dependency. |
| node-watch | npm | You want a tiny wrapper over fs.watch with filtering and recursion and can live with fewer edge-case fixes. |
| watchpack | npm | You are in webpack's orbit and want the watcher webpack itself uses, with its aggregation semantics. |