Get E-Book
File System

File Watching and Atomic Replacement

Ishtmeet Singh @ishtms/February 22, 2026/45 min read
#nodejs#file-system#fs-watch#atomic-writes#inotify

File watchers report filesystem activity, not completed application state. fs.watch() connects JavaScript callbacks to operating-system watcher mechanisms, while fs.watchFile() reaches the same problem from the other direction by polling metadata. Atomic replacement adds one more layer: the writer usually publishes a temporary file by renaming it over the target, so the watcher often observes a name change rather than a write.

File Watching and Atomic Replacement

That difference shapes production watcher code. A change event means the watcher saw filesystem activity; it does not prove the file is ready for parsing. Config reloaders therefore tend to wait briefly, stat the path, read the full file, validate it, and only then swap application state.

fs.watch('./config.json', callback) hides the operating-system watcher underneath it: inotify on Linux, kqueue or FSEvents on macOS depending on the target, and ReadDirectoryChangesW on Windows. Node exposes one API over those different systems, so the API inherits their different event shapes, queue behavior, and failure modes.

Atomic writes meet that inconsistency directly. When editors save files, they often write to a temp file and rename it into place. Watchers then see rename events, not writes. File watching and atomic replacement need to be read together because the same save operation belongs to both mechanisms.

fs.watch() and the OS Event Layer

At the JavaScript layer, the surface is small:

js
import fs from 'node:fs';

const watcher = fs.watch('./config.json', (event, filename) => {
  console.log(`${event}: ${filename}`);
});
watcher.on('error', err => console.error(err.code));

The callback receives an event, either 'change' or 'rename', and a filename when the platform can provide one. Sometimes filename is null, so watcher code still needs a fallback. The returned FSWatcher is an EventEmitter, which is where later watcher failures arrive as 'error' events.

Those two event types cover every filesystem operation Node reports through this API. A content modification often arrives as 'change'; a deletion, rename, or creation often arrives as 'rename'. Some platforms can report both for a single logical save. The event string alone cannot tell you whether a file was deleted, created, or renamed into place, so it should be treated as a prompt to inspect the path again.

How Events Map to OS Mechanisms

On Linux, fs.watch() is built on inotify through libuv. An inotify instance has a file descriptor, and watched paths receive masks such as IN_MODIFY, IN_ATTRIB, IN_DELETE_SELF, IN_MOVE_SELF, and several others. Node collapses those richer masks into 'change' or 'rename': modify events and metadata attribute changes commonly map to 'change', while lifecycle events such as delete, create, and move commonly surface as 'rename'.

On macOS, Node uses kqueue for file watches and FSEvents for directory watches. FSEvents operates at the directory-tree level and can request file-level notifications, but it still coalesces rapid events. Ten modifications to the same file in quick succession might produce fewer notifications than Linux. macOS optimizes for low overhead across large directory trees, not exact event counts.

On Windows, ReadDirectoryChangesW watches directories and reports specific actions: FILE_ACTION_MODIFIED, FILE_ACTION_ADDED, FILE_ACTION_REMOVED, FILE_ACTION_RENAMED_OLD_NAME, FILE_ACTION_RENAMED_NEW_NAME. The API is buffer-based and asynchronous. If events arrive faster than you drain them, the buffer can overflow. Windows reports this as ERROR_NOTIFY_ENUM_DIR; by then, the specific change records are gone and the safe recovery path is a directory rescan.

A short operation sequence shows why the collapsed API cannot promise identical behavior. Suppose you write to a file, append to it, then rename it:

js
import fs from 'node:fs';

fs.writeFileSync('./test.txt', 'hello');
fs.appendFileSync('./test.txt', ' world');
fs.renameSync('./test.txt', './test-renamed.txt');

On one Node v24 Linux run, a file watcher for that sequence reported change, change, change, then rename. A parent-directory watcher reported the modification events plus rename events for the old and new names. Another kernel, filesystem, editor, or backend can collapse or split the same logical operation differently. macOS might coalesce the two writes into a single change event, or skip reporting the modification if the rename follows closely enough. Windows can report rename pairs through its native API.

The portable conclusion is narrow but important: watcher code cannot depend on exact event counts or ordering.

Many filesystem operations converge into a watcher callback that rechecks the path before using the file

Figure 4.10 — Watcher events are coarse notifications. Portable code treats the callback as a hint, then inspects the path, reads the file, and validates the contents before changing application state.

Watching Directories vs. Files

Watching a directory gives you events for anything that happens inside it: files created, modified, deleted, or renamed. The filename parameter usually tells you which file changed. Watching a single file is narrower, and on systems such as Linux it attaches the watch to the specific inode behind that path.

That inode detail is where file-level watching becomes fragile. If you watch config.json and something deletes it, the inotify watch on that inode becomes invalid on Linux. A new file created at the same path has a different inode. Your watcher is still attached to the old object, while the replacement path was never registered.

Ordinary editor and deploy workflows hit this path. Text editors routinely delete and recreate files during save operations. Some tools, for example, write your changes to config.json.tmp, rename config.json to config.json~ (backup), then rename config.json.tmp to config.json. From the watcher's perspective, the original file was renamed away. On Linux, the watch attached to the original inode might now be pointing at config.json~, not the new config.json.

For this reason, most production code watches the parent directory instead. If the file gets deleted and recreated, which is exactly what happens during atomic writes and editor safe-saves, the directory watcher catches the rename event because the directory itself still exists. File-level watchers often miss the replacement.

A file-level watch stays with a replaced file object while a parent-directory watch remains on the stable folder

Figure 4.11 — A file-level watch can remain attached to the old file object after replacement. A parent-directory watch stays attached to the stable directory and filters events by filename.

The recursive Option

js
fs.watch('./src', { recursive: true }, (event, filename) => {
  console.log(`Changed: ${filename}`);
});

On macOS, recursive directory watching uses FSEvents' native tree support. One watch can cover the directory tree.

On Windows, ReadDirectoryChangesW supports a recursive flag natively, with similar efficiency.

On Linux, recursive: true was historically unsupported. Node added recursive Linux support in v19.1 by internally walking the directory tree and setting up individual inotify watches on subdirectories. That costs more than macOS or Windows native recursive watching. Each subdirectory consumes one of your inotify watch slots, and Node needs to detect newly created subdirectories and add watches for those too.

That internal fan-out makes the inotify watch limit part of application behavior. The limit is finite and host-specific. Check it with cat /proc/sys/fs/inotify/max_user_watches; on the Linux host used to fact-check this chapter, it was 524288, while lower values such as 8192 or 65536 still show up on some systems. A project with more watched directories than the configured limit gets ENOSPC, which has nothing to do with disk capacity here. The inotify watch table is full. Raise the runtime value with:

bash
echo 524288 | sudo tee /proc/sys/fs/inotify/max_user_watches

That command changes the current runtime value. Make it persistent with a sysctl config file if the machine needs the higher limit after reboot.

This is why a watcher that works on macOS can fail on a Linux server with an ENOSPC error that refers to the inotify watch table, not disk capacity.

The persistent Option and Keeping the Process Alive

By default, an active watcher keeps the process alive. The event loop sees a live handle and will not exit. Set persistent: false if you want the watcher to be passive: it fires while the process is alive for other reasons, but it will not keep the process running on its own.

js
fs.watch('./file.txt', { persistent: false }, (event) => {
  console.log('Changed');
});

A common script bug follows from that default. The script watches a file, handles one change, and then appears to hang because the watcher still holds the event loop open. If the script should terminate after one change, either close the watcher in the callback or use persistent: false. That option controls process lifetime; it is not a cleanup strategy for long-running code.

Watcher Errors

Watcher setup can fail before an FSWatcher object exists. Later failures arrive through the watcher's 'error' event. Handle both paths:

js
let watcher;
try {
  watcher = fs.watch(targetPath, onChange);
  watcher.on('error', reportWatchError);
} catch (err) {
  reportWatchError(err);
}

The common failure modes are ordinary filesystem failures expressed through watcher setup and delivery: watching a path that does not exist (ENOENT), hitting the inotify limit on Linux (ENOSPC), losing access after permissions change, or having the watched directory deleted. On Node v24, watching a missing path throws ENOENT synchronously on Linux. Platform behavior after deletion differs, so keep both the setup try/catch and the event listener.

Closing Watchers

js
watcher.close();

Closing releases the OS-level watch handle. On Linux, that is the inotify watch descriptor. On macOS, it may be a kqueue-backed file watch or an FSEvents-backed directory watch. The cleanup principle is the same as closing file descriptors: the OS has finite resources, and leaking watchers in a long-running process eventually hits limits.

Current Node.js releases also support AbortSignal for watcher cleanup:

js
const controller = new AbortController();
fs.watch(dir, { signal: controller.signal }, onChange);
controller.abort();

If watchers are created at runtime, say one per user session or one per uploaded file, track them in a Map and close them when the associated resource is cleaned up. Leaking 10 watchers a minute adds up to 14,000 leaked watchers per day, enough to hit the inotify limit within hours.

fs.watchFile() and Stat Polling

fs.watchFile() ignores OS event mechanisms entirely. It polls the file's metadata with fs.stat() at a fixed interval and fires a callback when anything changes.

js
fs.watchFile('./config.json', { interval: 2000 }, (curr, prev) => {
  console.log(`mtime: ${prev.mtime} -> ${curr.mtime}`);
  console.log(`size: ${prev.size} -> ${curr.size}`);
});

The callback gets two Stats objects: current and previous. Your code can compare mtime, ctime, size, permission bits, link count, or any other field exposed by the Stats object. Node invokes the listener when the polled stat state changes. To detect content modifications, compare curr.mtimeMs and prev.mtimeMs.

How Polling Works Internally

Node maintains internal state for files watched with fs.watchFile(). A timer runs at the specified interval, and on each tick Node polls watched paths with stat-style filesystem operations. When it observes a stat change, it invokes the callback with the previous and current Stats objects.

Because every watched path is polled, the cost scales linearly. Watching 100 files at a 1-second interval means roughly 100 metadata checks per second. Short intervals across thousands of files are real filesystem load, and they can add latency next to reads, writes, DNS lookups, or crypto work that also uses libuv resources.

Why the Default Interval Is 5007ms

The default is not 5000. Node documents it as 5007ms. Treat that as a historical default, not a timing contract. If you care about detection latency or filesystem load, set the interval explicitly and measure it on the filesystem you deploy to.

You can set any interval you want:

js
fs.watchFile('./config.json', { interval: 1000 }, (curr, prev) => {
  if (curr.mtimeMs !== prev.mtimeMs) {
    console.log('Content changed');
  }
});

Shorter intervals give faster detection at higher cost. For config files that change once per deploy, 10 or 30 seconds is often fine. For a dev server, 1 second might be acceptable. If you need sub-second detection, polling is the wrong tool; use fs.watch().

When Polling Beats Events

Polling works better on many network filesystems. NFS, CIFS, and SMB can have unreliable or absent event delivery because writes happen through a remote server. fs.watch() can be the wrong tool there. fs.watchFile() calls stat, which queries the remote filesystem's metadata directly. It is slower, but it can still function.

The same tradeoff helps in other situations where fs.watch() breaks: after file deletion and recreation on Linux, on platforms with unreliable event delivery, in containers where the filesystem overlay driver does not support inotify, or on filesystems that simply do not implement the notification API. Docker volumes with certain storage drivers are a common example. fs.watch() produces no events, but polling catches changes through metadata snapshots.

Stopping a Stat Watcher

js
const watcher = fs.watchFile('./config.json', onStatChange);
watcher.unref();
fs.unwatchFile('./config.json', onStatChange);

Current Node.js releases return an fs.StatWatcher object from fs.watchFile(). It has ref() and unref(), but no .close() method, so cleanup still goes through fs.unwatchFile(). Passing only the filename removes all listeners for that file. Passing a specific listener function removes just that listener.

mtime Precision Limits

fs.watchFile() depends on stat changes to detect modifications. Modern filesystems (ext4, APFS, NTFS, ZFS) can expose subsecond timestamps, and Node also exposes nanosecond stat fields when bigint: true is used. Older filesystems like FAT32, some network mounts, and some virtualized filesystems can have coarser timestamp resolution. On those filesystems, two writes inside the same timestamp tick can collapse into one visible stat state.

mtime usually changes when a write or truncate operation modifies file data, but the exact observation point depends on the filesystem and kernel behavior. The poller only sees snapshots at its interval. A file can be opened, changed, changed again, and closed between two polls; fs.watchFile() only sees the final stat state.

Choosing Between fs.watch() and fs.watchFile()

fs.watch() is fast. Events often fire within milliseconds on Linux, sometimes with more latency on macOS, and the approach is efficient because it uses kernel event delivery rather than polling. The cost of that speed is inconsistency. It can miss events, fire duplicate events, or break entirely after file deletions. Its vocabulary is also too coarse: just 'change' and 'rename' for many distinct filesystem operations.

fs.watchFile() gives up that speed. Detection lag equals your polling interval, and subsecond polling still misses changes on many filesystems. It is also more expensive because stat calls run on every tick and can compete with other filesystem work. Its advantage is that it can work where event APIs do not. If the relevant metadata state differs between two polls, you see a callback.

For cross-platform development tools and recursive watchers, production code often puts a wrapper library above both APIs instead of using either one directly.

Why chokidar Exists

chokidar is a third-party file-watching library that wraps both APIs and normalizes behavior across platforms. Install it as an application dependency before using it, and check the option names for the major version you ship. Its value is not just the callback surface; it handles the pieces you would otherwise have to build around raw watchers:

Recursive watching on Linux. chokidar walks the directory tree, sets up inotify watches for subdirectories, and adds or removes watches as directories appear or disappear.

Event normalization. Instead of only 'change' and 'rename', chokidar emits 'add', 'change', 'unlink', 'addDir', and 'unlinkDir', which map more closely to what happened to the filesystem.

Editor safe-write detection. When an editor writes to a temp file and renames it over the target, chokidar consolidates the delete-then-create sequence into a single 'change' event instead of flooding you with 'unlink' and 'add'.

Polling support. On filesystems where event delivery is unreliable, chokidar can use stat polling through its usePolling and interval options. Some environments need that explicit configuration.

Initial scan. When you start watching a directory, chokidar can emit 'add' events for all existing files, so your code can initialize state based on what's already there.

js
import chokidar from 'chokidar';

const watcher = chokidar.watch('./src', {
  ignored: /node_modules/,
  persistent: true,
});
watcher.on('change', p => console.log(`Changed: ${p}`));
watcher.on('add', p => console.log(`Added: ${p}`));

Many build tools, test runners, and framework dev servers use chokidar or equivalent watcher layers internally. For watching a single known file on a known platform, raw fs.watch() can be enough. For anything cross-platform or recursive, a library usually gives you the behavior you were going to build around the raw watcher anyway.

File Watching in Practice

The production patterns all follow the same rule: treat watcher events as hints, then verify state before acting. The shape changes by use case, but the watcher callback should usually schedule verification rather than mutate application state immediately.

Config File Hot Reload

You load config on startup. You want changes to take effect without restarting the server.

js
import fs from 'node:fs';
import path from 'node:path';

const configPath = path.resolve('./config.json');
let config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
js
const reloadConfig = debounce(() => {
  try {
    const raw = fs.readFileSync(configPath, 'utf8');
    config = JSON.parse(raw);
    console.log('Config reloaded');
  } catch (err) {
    console.error('Bad config, keeping old:', err.message);
  }
}, 500);
js
const dir = path.dirname(configPath);
const base = path.basename(configPath);

fs.watch(dir, (event, filename) => {
  if (filename == null) return reloadConfig();
  if (filename === base) reloadConfig();
});

The try/catch is doing real work here. If the new config is invalid JSON, you keep the old config instead of crashing. The debounce handles editors that save in multiple steps. The parent-directory watch survives delete-and-recreate save patterns better than a file-level watch. The synchronous read blocks briefly in the watcher callback, but it still needs validation; a blocking read proves that bytes were read, not that the writer produced valid configuration.

Watching a Directory for New Files

The same rule appears when a directory receives uploaded files, incoming data drops, or queued work items:

js
import fs from 'node:fs';
import path from 'node:path';

const uploadDir = './uploads';
const processed = new Set();

fs.watch(uploadDir, (event, filename) => {
  if (filename == null) return scanUploads();
  const fullPath = path.join(uploadDir, filename);
  if (processed.has(fullPath)) return;
  queueIfStable(fullPath);
});
js
async function queueIfStable(fullPath) {
  const first = await fs.promises.stat(fullPath).catch(() => null);
  if (!first?.isFile()) return;
  await new Promise(r => setTimeout(r, 250));
  const second = await fs.promises.stat(fullPath).catch(() => null);
  if (!second || first.size !== second.size) return;
  processed.add(fullPath);
  processFile(fullPath);
}

A 'rename' event fires for both creation and deletion, so the first stat distinguishes files that currently exist from names that disappeared. The second stat gives simple protection against processing a file while another process is still writing it. When filename is missing, scan the directory and apply the same stability check. For high-volume uploads, require writers to upload under a temporary name and rename into uploads only after the file is complete.

Log Rotation Detection

Your app writes to app.log. An external tool renames it to app.log.1 and expects your app to start writing to a fresh app.log.

js
import fs from 'node:fs';

let logStream = fs.createWriteStream('./app.log', { flags: 'a' });
let watcher;
js
function reopenLog() {
  watcher?.close();
  logStream.end(() => {
    logStream = fs.createWriteStream('./app.log', { flags: 'a' });
    logStream.once('open', watchLog);
  });
}
js
function watchLog() {
  watcher = fs.watch('./app.log', event => {
    if (event === 'rename') reopenLog();
  });
  watcher.on('error', err => console.error(err.message));
}
watchLog();

When log rotation renames the file, a 'rename' event usually fires. You close the old stream, which may now point at app.log.1 through its open fd, and open a new stream for app.log. The watcher restarts after the new stream opens because, on Linux, fs.watch tracks the inode that was just renamed. This pattern handles rename-based rotation. It does not handle copy-truncate rotation; that keeps the same inode and needs different log reopening logic.

Watching Network Filesystems

fs.watch() relies on the local kernel's event infrastructure. When files live on NFS, SMB, or CIFS mounts, the writes happen on a remote machine. The local kernel has no visibility into those writes and produces no inotify/FSEvents/ReadDirectoryChangesW events.

For network-mounted files, use stat polling via fs.watchFile() or move notification outside the filesystem entirely: HTTP webhooks, message queues, or a database trigger that signals when the remote data changes.

Watcher Resource Leaks

Every watcher holds OS resources. On Linux, each fs.watch() call consumes an inotify watch descriptor. On macOS, file and directory watches use different OS backends. On all platforms, the watcher keeps at least one file descriptor or handle open.

If your application creates watchers at runtime, one per user session, one per uploaded file, or one per API request, and leaves them open, resources accumulate. A leak of 10 watchers per minute becomes 14,400 per day. On Linux, that can hit the inotify limit or the fd limit. The symptoms are confusing: new watchers start failing with ENOSPC or EMFILE, or the process starts running out of memory from the accumulated JavaScript watcher objects and their callbacks.

Pair watcher creation with cleanup. Use a Map to track watchers by key, such as user ID, file path, or session, and explicitly close them when the associated resource is released.

js
const watchers = new Map();

function startWatching(key, filePath) {
  if (watchers.has(key)) return;
  const watcher = fs.watch(filePath, () => handleChange(key));
  watcher.on('error', err => reportWatchError(key, err));
  watchers.set(key, watcher);
}
js
function stopWatching(key) {
  const watcher = watchers.get(key);
  watcher?.close();
  watchers.delete(key);
}

In tests, close all watchers in afterEach hooks. Leaked watchers keep the event loop alive and cause test processes to hang until the test runner times out.

Assuming Events Are Immediate

Even fs.watch(), the event-based approach, has latency. On Linux, inotify events usually arrive within a few milliseconds. On macOS, directory watches backed by FSEvents can batch events and deliver them after a delay, sometimes 100ms or more, sometimes a full second if the system is under load. On Windows, ReadDirectoryChangesW has similar variability.

If you have hard real-time requirements, such as detecting a change within 10ms, file watching alone might not be reliable enough. You would need a more direct signaling mechanism: the writer explicitly notifying the reader via IPC, a Unix domain socket, or a shared memory flag.

Debouncing File Watch Events

A single logical change, such as saving a file in an editor, can produce two, three, or more events. The editor might write to a temp file, delete the original, and rename the temp file. Or the OS might fire separate events for the write operation and the subsequent metadata update when the file is closed. One save becomes a burst.

If your event handler rebuilds a project, reloads a config, or uploads a file, running it three times is wasteful and potentially harmful. Reloading a config three times in 50ms is not useful work. Debouncing delays the action until events stop arriving for a specified duration.

The implementation is small:

js
function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

Each event resets the timer. Only after delay milliseconds of silence does the function actually run. Wrap your watcher callback:

js
const onChange = debounce((event, filename) => {
  console.log('File settled, reloading...');
}, 300);
fs.watch('./', (event, filename) => {
  if (filename === 'config.json') onChange(event, filename);
});

The delay depends on context. 100-300ms works for development tools: short enough that developers do not notice, long enough to catch multi-event bursts from editor saves. Config reloading in production might use 500ms or 1 second because the deploy script should be finished before you reload. Log directory scanning might skip debouncing entirely because you want to process new files the moment they appear.

Stat-Based Deduplication

A complementary approach is to ignore events where the file's observed metadata has not actually changed. When an event fires, stat the file and compare a small signature to what you saw last time.

js
let lastSeen = null;
fs.watch(path.dirname(configPath), async (event, filename) => {
  if (filename == null) return reloadConfig();
  if (filename !== path.basename(configPath)) return;
  const stats = await fs.promises.stat(configPath).catch(() => null);
  if (!stats) return;
  const signature = `${stats.mtimeMs}:${stats.size}`;
  if (signature === lastSeen) return;
  lastSeen = signature;
  console.log('Config actually changed');
});

This catches OS-level duplicate events and spurious metadata-only notifications. It is a cheap filter, not proof that the writer is finished. Size can stabilize before a writer closes the file, and content can change while keeping the same size.

When correctness is the goal, combine both techniques. Stat-check first to suppress duplicates, then debounce to wait for the burst of events to settle before acting.

Editor Safe-Write Patterns

Text editors often do more than open-write-close your file. Many use a "safe write" pattern to avoid leaving a corrupt file if the editor crashes mid-save. Vim, VS Code, Sublime Text, JetBrains IDEs, and others all do variations of this. Because these editors often use atomic writes internally, they can break naive file watchers.

Some command line tools, such as sed -i, or atomic-write libraries write to config.json.tmp, rename config.json to config.json~ as a backup, then rename config.json.tmp to config.json. Those are three filesystem operations, and they can produce three separate events. Vim's actual default behavior is slightly different, renaming the original to a backup and then writing directly to a newly created file, but watchers still see multiple events against changing inodes. VS Code writes to a randomly named temp file in the same directory, then renames it over the target. Some editors go further, creating the temp file in a completely different directory and moving it in, which might cross filesystems on certain setups.

At the file-watcher handoff, this sequence breaks file-level watching. If you're watching config.json at the file level on Linux, you see a 'rename' event when the original is renamed to the backup. Your inotify watch is now attached to an inode that points to config.json~, because inotify follows inodes, not names. The new config.json (created by renaming the temp file) has a completely different inode, so the watcher can miss the new content. From your watcher's perspective, the file was renamed away and nothing else happened.

On macOS, directory watching handles this somewhat better because Node uses FSEvents for directories and FSEvents reports changes in the watched tree. But the timing is unpredictable - you might get one event, two events, or three, depending on how fast the tools perform the rename dance.

Watching the parent directory instead of the file itself is the standard workaround. You see rename events for all the shuffling, filter by filename to find the ones you care about, and debounce to wait until the active writing is done. The directory watcher stays valid through the sequence because the directory's inode remains in place.

Editor save patterns are one reason chokidar exists. Detecting and consolidating these multi-step saves into a single "file changed" event requires tracking the sequence of events and recognizing the pattern. It's doable but tedious to implement from scratch.

Kernel Watcher Internals

The platform differences in file watching come from fundamentally different kernel designs. Once you see what each backend is trying to optimize, fs.watch() behavior starts to look less arbitrary, and it becomes clearer why no cross-platform wrapper can fully hide the differences.

inotify on Linux

inotify revolves around three syscalls: inotify_init1(), inotify_add_watch(), and read().

inotify_init1() creates an inotify instance and returns a file descriptor. That fd represents a queue of events. Like any fd, it participates in the event loop: libuv adds it to its epoll interest set and gets notified when events are available to read.

inotify_add_watch(fd, pathname, mask) registers a watch on a specific path. The mask parameter specifies which events you care about: IN_MODIFY (content changed), IN_ATTRIB (metadata changed), IN_CREATE (file created in a watched directory), IN_DELETE (file deleted from a watched directory), IN_MOVED_FROM / IN_MOVED_TO (rename source/destination), and others. The kernel returns a watch descriptor - an integer identifying this specific watch within the inotify instance.

When a matching event occurs, the kernel writes an inotify_event struct into the inotify fd's internal buffer. The struct contains the watch descriptor, an event mask, an optional cookie linking paired rename events so you can match MOVED_FROM with MOVED_TO, a length field, and the filename. Your process reads these structs with read() on the inotify fd, just like reading from a regular file or pipe.

libuv wraps this behind its uv_fs_event handle type. At a high level, Node creates a libuv fs-event handle for your path, libuv starts the platform watcher, and readiness on the underlying OS handle drives a callback back into JavaScript. The exact internal layout can change across libuv and Node versions. The stable JavaScript contract is much smaller: an FSWatcher, an 'error' event, optional filename, and event strings collapsed to 'change' or 'rename'.

inotify watches individual inodes. When you watch a directory, inotify gives you events for files in that directory, but only that directory, not subdirectories. To watch recursively, you need a separate inotify_add_watch() for every subdirectory in the tree. Each watch consumes a slot in the per-user inotify watch table, which has a configurable maximum (/proc/sys/fs/inotify/max_user_watches). A large project with thousands of nested directories can exhaust this limit, producing ENOSPC errors.

That makes inotify more event-granular than FSEvents. Filesystem operations produce masks such as IN_MODIFY, IN_ATTRIB, IN_MOVED_FROM, and IN_MOVED_TO, and the kernel queues events in the inotify buffer. The kernel can coalesce identical unread events, and it can drop events when the queue overflows. The queue limit is host-specific; check /proc/sys/fs/inotify/max_queued_events on Linux. So inotify gives you more detail than FSEvents, but it still is not an audit log.

FSEvents on macOS

FSEvents is architecturally different. It was designed for Spotlight, macOS's search indexer, and Time Machine, where the goal is monitoring enormous directory trees with low CPU overhead: your entire home directory, sometimes the whole disk. The design priorities are different from inotify: tree-level monitoring and low resource cost rather than event-granular precision.

FSEvents watches directory paths. When you register a path, you get notifications for that directory and everything below it. Node's fs.watch() uses kqueue for files on macOS and FSEvents for directories, so the exact backend depends on what you watch. For directory trees, one FSEvents stream can cover the subtree without one watch per subdirectory.

The kernel maintains an event stream per watched path. Events flow through a coalescing layer that batches rapid changes. If a file is modified ten times in 100ms, FSEvents might report one notification or two. The notification identifies which path was affected, but the event count depends on timing and the kernel's internal batching logic. FSEvents prioritizes "something changed here" over "here are all N individual changes that happened." For Spotlight indexing, this is ideal because you just need to re-index the file. For a file watcher that needs to count events or detect every intermediate state, it is a source of confusion.

FSEvents reports events asynchronously, sometimes with latency. When creating the stream, you configure a "latency" parameter - a minimum interval between event deliveries. The default in most implementations is around 1 second. libuv passes 0 for this parameter, requesting immediate delivery, but the kernel still coalesces when events arrive in bursts. There's no way to force truly synchronous, per-event delivery.

For directory watches, libuv can request file-level FSEvents notifications with kFSEventStreamCreateFlagFileEvents. Without that flag, callers get directory-level notifications. Even with file-level notifications, coalescing still applies: you can get per-file paths while rapid changes merge into fewer delivered events.

In practice, your fs.watch() callback can fire fewer times on macOS than on Linux for the same filesystem operations. Event counts differ, timing differs, and Node documents filename as optional even on platforms that support it, including macOS. Watcher code still needs fallback logic for null.

ReadDirectoryChangesW on Windows

Windows takes a third approach. ReadDirectoryChangesW() is a Win32 API that watches a directory handle for changes. You pass it a buffer, and Windows fills that buffer with change records as they arrive. Each record contains the action type (FILE_ACTION_MODIFIED, FILE_ACTION_ADDED, FILE_ACTION_REMOVED, FILE_ACTION_RENAMED_OLD_NAME, FILE_ACTION_RENAMED_NEW_NAME) and the relative filename.

The API supports recursive watching natively via a flag parameter, so Windows internally handles subdirectory monitoring. Rename events come as pairs, old name first and new name second, which is more useful than inotify's separate MOVED_FROM/MOVED_TO events with a cookie linking them.

The buffer-overflow problem is the main weakness. ReadDirectoryChangesW() writes events into a fixed-size buffer you provide. If the buffer fills before you drain it because many files changed rapidly, or because your application was slow to process them, Windows discards subsequent events. The API signals this failure by returning ERROR_NOTIFY_ENUM_DIR, which Node can propagate as an 'error' event on the watcher. You lose the specific events; your recovery path is to re-scan the directory manually to rebuild state. You can mitigate this by using a large buffer and processing events quickly, but there is no guaranteed upper bound on event volume during burst activity.

The Upshot

No wrapper library can make these systems behave identically. inotify is per-inode and mask-based, with a finite event queue. FSEvents is tree-oriented, coalesced, asynchronous, and optimized for low overhead over massive directory hierarchies. ReadDirectoryChangesW sits somewhere between them, with per-file event records but buffer-based delivery that can overflow and force a rescan.

A library like chokidar can debounce, deduplicate, confirm changes with stat checks, and normalize the event vocabulary. That normalization layer is substantial; the three-line fs.watch() surface hides most of the operational complexity.

The Problem With Naive File Writes

Overwriting a file with fs.writeFile() opens it with the 'w' flag by default. That flag truncates the file to zero bytes immediately, then writes the new content. Between truncation and write completion, the file is empty or partially written. If the process crashes in that window, whether from an uncaught exception, SIGKILL, or power loss, you lose both old data and new data. The file is either empty or contains a fragment of the new content.

Even without a crash, writes can be partial. Node internally writes data in chunks via the write() syscall. If the disk fills up after 60% of the data is written, you get ENOSPC, and the file contains 60% of the new content with none of the old content. The application might handle the thrown error correctly and log it, but the damage to the file is already done.

Readers see whatever bytes are on disk at the moment they open the file. If another process (or another part of your own process) reads the config file while you're mid-write, it gets incomplete data. For a JSON config, that means broken JSON. The parsing fails. The application that depends on that config crashes or falls back to defaults that may not be appropriate for production.

Services that read config files on every request take the worst version of this failure. A deploy script updates the config with fs.writeFile(). A request arrives during the write. The service reads half-old, half-new JSON. Parse error. 500 response. And the next request might work fine because the write finished. The failure depends on timing between the request and the write.

The 'r+' flag avoids truncation because it opens for reading and writing at the current position. But then you need to manually truncate after writing if the new content is shorter. A crash between writing and truncating leaves stale bytes from the old content appended to the new content. There is no flag combination that makes in-place overwrites safe. The filesystem simply does not offer "atomically replace file contents" as a primitive. Open, write, and close are separate syscalls, and a failure between any of them can corrupt the file.

The Temp-File-and-Rename Pattern

The safer pattern writes to a temporary file, then renames the temp file over the target. The rename() syscall on POSIX is atomic when source and destination are on the same filesystem. It swaps the directory entry's inode pointer in a single operation. Readers see either the old file if they opened it before the rename, or the new file if they opened it after. A half-written state is impossible.

A same-directory temporary file is renamed over the target so readers see only complete file versions

Figure 4.12 — Atomic replacement publishes a complete temporary file with one same-filesystem rename. Readers opening the pathname before or after the rename see complete content, not the write in progress.

js
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';

const dir = path.dirname(targetPath);
const base = path.basename(targetPath);
const suffix = crypto.randomBytes(6).toString('hex');
const tempPath = path.join(dir, `.${base}.tmp-${suffix}`);
await fs.promises.writeFile(tempPath, data, { flag: 'wx' });
await fs.promises.rename(tempPath, targetPath);

The temp path lives in the target directory, so the final rename stays on the same mounted filesystem. The 'wx' flag means "write exclusively": fail if the file already exists. Underneath, that is O_WRONLY | O_CREAT | O_EXCL at the syscall level. The O_EXCL flag makes the check-and-create atomic in the kernel. If two processes generate the same random suffix, only one succeeds. The other gets EEXIST and can retry with a different name. Node's docs also warn that exclusive mode may not work reliably on network filesystems, so shared mounts need extra caution.

Why rename() Is Atomic on POSIX

On POSIX, rename() updates directory entries. A directory is a mapping from names to inode numbers. When you rename file A to file B in the same directory on the same filesystem, the kernel updates B's directory entry to point to A's inode and removes A's entry. Both operations happen within a single filesystem transaction. The POSIX spec requires atomicity: rename either fully completes or fully fails. No intermediate state is visible to other processes.

If B already exists, its old inode is dereferenced and its link count is decremented. Processes that already had B's old inode open via a file descriptor can still read from it because the data is not deleted until the last fd is closed and the link count reaches zero. Any process opening B after the rename gets the new inode.

Why the Same Filesystem Is Required

rename() is atomic only when both paths resolve to the same mounted filesystem. If the temp file is on /tmp and the target is on /var/app, the kernel cannot atomically update two different filesystems' directory entries. On Linux, you get an EXDEV error, "cross-device link", and the rename fails outright. Create temp files in the same directory as the target.

Docker environments often expose this split because /tmp might be a different mount from your application's data directory. The same issue appears when os.tmpdir() returns a path on a tmpfs ramdisk while your data lives on a persistent volume.

Permissions After Rename

After rename, the target inherits the temp file's inode, including its permissions and ownership. If the original config file was 0644 owned by www-data, and your temp file is 0600 owned by deploy, the renamed file keeps the temp file's metadata. Your application running as www-data can no longer read its own config.

To preserve the original's permissions:

js
const original = await fs.promises.stat(targetPath).catch(() => null);
const mode = original ? original.mode & 0o777 : 0o666;
await fs.promises.writeFile(tempPath, data, {
  flag: 'wx',
  mode,
});
if (original) await fs.promises.chmod(tempPath, mode);
await fs.promises.rename(tempPath, targetPath);

The mode option is only the creation request, and the process umask can still clear bits from it. If exact POSIX permission bits count, call chmod(tempPath, original.mode & 0o777) before the rename. This preserves mode bits, not ownership, ACLs, extended attributes, or platform-specific metadata. chown() requires root or matching ownership, so it is often unavailable in non-root deployments.

Windows Differences

Windows file sharing modes interact forcefully with rename(). On POSIX, atomicity permits replacing an open file while the old inode stays alive until its last file descriptor closes. On Windows, replacement can fail when another process opened the target without delete-sharing permissions. Node can surface that as EPERM or EACCES. The atomic operation aborted cleanly, but the replacement was blocked by the other handle's sharing mode.

The workaround is retry-with-backoff:

js
async function renameWithRetry(tempPath, targetPath) {
  for (let i = 0; i < 5; i++) {
    try {
      await fs.promises.rename(tempPath, targetPath);
      return;
    } catch (err) {
      if (!['EPERM', 'EACCES'].includes(err.code) || i === 4) throw err;
      await new Promise(r => setTimeout(r, 50 * (i + 1)));
    }
  }
}

Eventually the other process may close its handle, and the rename can succeed. Production code should cap the total wait and log the final failure. Windows also provides the ReplaceFile() Win32 API for replacement workflows that preserve more file metadata, but it still depends on access rights and sharing modes. Node does not expose it through fs.rename().

Creating Temporary Files and Directories

fs.mkdtemp() for Temp Directories

js
import { mkdtemp } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

const dir = await mkdtemp(path.join(os.tmpdir(), 'myapp-'));

This generates something like /tmp/myapp-a7F3kL and creates the directory atomically with a random suffix. os.tmpdir() returns the system's designated temp location: /tmp on Linux, a per-user /var/folders/... path on macOS, and %LOCALAPPDATA%\Temp on Windows.

For atomic writes, though, do not put temp files in os.tmpdir(). Put them in the same directory as the target file so the rename stays on the same filesystem.

fs.mkdtemp() is for scratch work: staging multiple files before moving them, running isolated build steps, or creating workspace directories for batch operations. For single-file atomic writes, create a temp file directly in the target directory with a random name and O_EXCL.

TOCTOU and the Role of O_EXCL

TOCTOU, time-of-check-time-of-use, is a race condition class specific to filesystem operations. You check if a file exists, then create it based on that result. Between check and create, another process can intervene.

js
// Vulnerable to TOCTOU:
const exists = await fs.promises.access(tempPath)
  .then(() => true, () => false);
if (!exists) {
  await fs.promises.writeFile(tempPath, data);
}

Between access() and writeFile(), another process, or an attacker on a shared system, could create a file at tempPath or plant a symlink there redirecting your writes to another location. writeFile() with the default 'w' flag follows the symlink and writes to its target.

O_EXCL, exposed by the 'wx' flag, eliminates the race. The check and the create happen in a single atomic syscall inside the kernel. Either the file does not exist and you create it, or it already exists and you get EEXIST. There is no gap between checking and creating, and no window for an attacker or a competing process to exploit.

TOCTOU vulnerabilities are well-documented in security literature. They are particularly concerning on multi-user systems, CI environments, and shared development machines, anywhere multiple processes or users operate in the same directories. Even on single-user production servers, concurrent processes such as multiple application instances, cron jobs, and deploy scripts can trigger TOCTOU races if you use check-then-create patterns instead of O_EXCL.

Cleaning Up Temp Files

Temp files accumulate when processes crash between creating the temp file and renaming it. A few simple habits keep that accumulation bounded.

Include timestamps in temp file names, such as .tmp-1708538400000-a1b2c3. On startup, scan the directory for temp files older than a threshold and delete them. This is easy to implement, and it lets you tell at a glance how old an orphan is.

Track active temp files in a Set during the process lifetime. Delete them in finally blocks after each atomic write. Register a process.on('exit') handler that synchronously cleans any remaining tracked files:

js
import fs from 'node:fs';

const tracked = new Set();
process.on('exit', () => {
  for (const p of tracked) {
    try { fs.unlinkSync(p); } catch {}
  }
});

Exit handlers do not run on SIGKILL or segfaults, so they are a best-effort mechanism. Startup cleanup is the backstop for those cases. Accept that some orphaned temp files will exist occasionally and design your system to tolerate a few stale .tmp-* files in the data directory.

For long-running servers, consider a periodic cleanup routine that scans for temp files older than a threshold:

js
async function cleanStaleTemps(dir, maxAgeMs = 3600000) {
  const now = Date.now();
  for (const name of await fs.promises.readdir(dir)) {
    const match = /^\.tmp-(\d+)-/.exec(name);
    if (!match || now - Number(match[1]) <= maxAgeMs) continue;
    await fs.promises.unlink(path.join(dir, name)).catch(() => {});
  }
}

Run this on startup and optionally on a timer. It extracts the timestamp from the temp file name and deletes anything older than the threshold (1 hour by default). The .catch(() => {}) handles the case where the file was already deleted by another process or the rename completed between the readdir and the unlink.

Atomic Writes and File Watching Together

The rename in an atomic write usually reaches watchers as a 'rename' event. If you are watching a config file and a deploy script atomically replaces it, the watcher may see a rename instead of a content change. Code that only handles 'change' events can miss the update.

The reliable approach is a parent-directory watch that ignores the event type and re-reads the file after debouncing.

js
const reload = debounce(async () => {
  try {
    const raw = await fs.promises.readFile(configPath, 'utf8');
    config = JSON.parse(raw);
  } catch (err) {
    console.error('Keeping old config:', err.message);
  }
}, 500);
js
fs.watch(path.dirname(configPath), (event, filename) => {
  if (filename == null) return reload();
  if (filename === path.basename(configPath)) reload();
});

Any matching event triggers a reload attempt. The debounce handles the burst of events that rename operations produce. The try/catch handles editor save sequences where a backup rename leaves the target name absent briefly. Directory-level watching survives file deletion and recreation because the directory inode still exists. A file-level watcher can break after replacement on Linux because the watched inode is gone.

A Complete Atomic Write Implementation

Putting the pieces together gives a compact implementation. This version uses the renameWithRetry() helper from the Windows section:

js
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
js
function tempNameFor(targetPath) {
  const dir = path.dirname(targetPath);
  const base = path.basename(targetPath);
  const suffix = crypto.randomBytes(6).toString('hex');
  return path.join(dir, `.${base}.${Date.now()}-${suffix}.tmp`);
}
js
async function atomicWrite(targetPath, data, options = {}) {
  const tempPath = tempNameFor(targetPath);
  const original = await fs.promises.stat(targetPath).catch(() => null);
  const mode = original ? original.mode & 0o777 : options.mode ?? 0o666;
  const writeOptions = { ...options, mode, flag: 'wx' };
  try {
    await fs.promises.writeFile(tempPath, data, writeOptions);
    await fs.promises.chmod(tempPath, mode);
    await renameWithRetry(tempPath, targetPath);
  } catch (err) {
    await fs.promises.unlink(tempPath).catch(() => {});
    throw err;
  }
}

This creates a temp file beside the target with a timestamped random name and O_EXCL exclusivity. It preserves the original file's POSIX permission bits when the file exists, including the extra chmod() needed after creation-time umask filtering. It keeps flag: 'wx' after caller options so callers cannot accidentally disable exclusive creation. Then it renames atomically and cleans up the temp file if either the write or rename fails. Callers opening the target path see old content or complete new content, not a partial write.

For cross-platform use, wrap the rename in a retry loop for Windows EPERM/EACCES errors. For strict durability guarantees, including power-loss survival, call fsync() on the temp file's fd before closing it, and fsync() on the directory fd after the rename. Most applications do not need that level of durability, but databases and transaction logs do.

The overhead is one extra write to the temp file and one rename syscall. For small config files and JSON state, that is usually negligible. For large data files, the temp file doubles your write I/O because you write the data once to the temp file, then the rename is nearly instant because it is a directory entry update, not a data copy. When atomicity does not count, as with append-only logs, ephemeral scratch files, or data you can regenerate, write directly.

When to Use Atomic Writes

Configuration files are the default case for atomic writes. A corrupt config file means your application cannot start, and manual recovery usually means restoring a known-good file and restarting the service. The atomic write pattern costs almost nothing for a few KB of JSON.

Application state files have the same shape: caches, session stores, feature flags persisted to disk. If your application reads these on startup, corruption means data loss or broken behavior. Atomic writes preserve a complete old byte sequence or a complete new byte sequence. They do not prove that the new content is semantically valid, so validate before rename when validity is required.

PID files are smaller, but the failure mode is still serious. A truncated PID file might cause your process manager to think the service is not running and spawn a second instance. Writing the PID atomically avoids this.

Where you do not need it: append-only logs that already use append-mode semantics and tolerate process crashes, temp files that are themselves ephemeral, and files generated by build tools that can be regenerated from source. Log durability and multi-process append ordering are separate problems.

The temp-file-and-rename pattern makes individual file updates atomic. Sometimes the unit of publication is larger: a deploy pushing new static assets, a build tool producing several output files, or a migration updating both a data file and its index.

Individual atomic renames do not solve that case. If you rename file A successfully but crash before renaming file B, a reader might see the new A with the old B. The files are individually intact, but the set is inconsistent.

The symlink swap pattern solves this. Write all files into a new versioned directory, then atomically swap a symlink that points to the "current" version:

js
const dataDir = './data';
const newDir = path.join(dataDir, `v-${Date.now()}`);
await fs.promises.mkdir(newDir, { recursive: true });
await fs.promises.writeFile(path.join(newDir, 'config.json'), configData);
await fs.promises.writeFile(path.join(newDir, 'index.dat'), indexData);

After both files are written, swap the symlink:

js
const tmpLink = path.join(dataDir, `.current-tmp-${Date.now()}`);
await fs.promises.symlink(path.basename(newDir), tmpLink);
await fs.promises.rename(tmpLink, path.join(dataDir, 'current'));

The symlink target is path.basename(newDir) because relative symlink targets are resolved relative to the directory containing the link. A target such as ./data/v-123 inside ./data/.current-tmp-123 would point at ./data/data/v-123. The rename() on the symlink is atomic on POSIX filesystems. Before the rename, ./data/current points to the old version directory. After the rename, it points to the new one. Readers following the current symlink see a consistent set of files: either all old or all new, rather than a mix.

The old version directory sticks around until you clean it up. You can keep a few old versions for rollback, or delete them after a grace period. Windows symlink creation can require privileges or developer mode, and many Windows deployments use a different indirection layer instead of symlink swaps.

Durability vs. Consistency

The atomic write pattern gives you visible consistency: readers see old content or complete new content. Consistency and durability are different guarantees. Consistency describes what readers can observe. Durability describes what survives a power failure or kernel crash.

fs.writeFile() writes to the kernel's page cache, not directly to disk. The kernel flushes dirty pages to physical storage on its own schedule. On many Linux systems, dirty-page expiration defaults around 30 seconds, but that is kernel tuning, not a Node guarantee. If power fails before the flush, your "written" data is lost. The file either contains stale content or nothing, depending on what was physically on disk.

For durability, you need fsync(). Call it on the temp file's fd after writing but before the rename:

js
const handle = await fs.promises.open(tempPath, 'wx');
try {
  await handle.writeFile(data);
  await handle.sync();
} finally {
  await handle.close();
}
await fs.promises.rename(tempPath, targetPath);

handle.sync() forces the kernel to flush the file's data and metadata to physical storage. After it returns, the data is on disk, assuming the disk is not lying about having flushed. Some drives have write caches that complicate this, but that is a hardware problem, not a software one.

For crash-durable replacement, also sync the directory after the rename:

js
const dir = await fs.promises.open(path.dirname(targetPath), 'r');
try { await dir.sync(); }
finally { await dir.close(); }

The rename updates directory data: the name-to-inode mapping. That update also sits in the page cache until flushed. If power fails between the rename and the directory sync, the rename might not persist. Opening and syncing directories works on common POSIX filesystems, including the Linux host used for this chapter's fact checks, but it is platform- and filesystem-dependent. Databases like SQLite and PostgreSQL do this. Most applications don't need to pay the directory-sync cost on every small config write.

File watching and atomic replacement meet at one point: a pathname changed. The watcher callback is a hint to re-check state, not proof that one specific operation happened. The writer's job is to publish complete data. The watcher's job is to wait, re-read, validate, and swap application state only after the new bytes make sense.