Article View

Scroll down to read the full article.

Node.js fs.watch and NFS: The Rename Event That Vanished Into the Ether

calendar_month July 13, 2026 |
Quick Summary: Troubleshoot Node.js fs.watch failing to detect file rename events on NFS mounts. Learn the root cause and implement a robust, albeit ugly, applic...

You’re staring at your Node.js application. It’s supposed to react instantly when a file changes on a critical data share. Specifically, when a file is renamed. But it’s not. It just... sits there, oblivious, while your data pipeline grinds to a halt. You’ve checked the logs. No errors. Just a gaping void where a rename event should be. Welcome to hell. We’ve all been there.

This isn't your average "fs.watch isn't firing" issue. This is a subtle, insidious bug that only rears its ugly head under a very specific, deeply painful set of circumstances: a Node.js process using fs.watch to monitor a directory that is, in fact, an NFS mount. And the specific event being missed? The rename event. Not change. Not delete. Just rename. Your app is tracking critical file lifecycles, and this one missing piece is enough to cause full-blown production outages.

A frustrated programmer with multiple monitors displaying cryptic logs
Visual representation

The Scenario: NFS, Node.js, and a Missing Rename

Here’s the setup: You have a Node.js service, perhaps an indexing agent or a content processing pipeline. It's deployed to a Linux host. This service monitors a specific directory for new or updated files using fs.watch(path, callback). Crucially, this path isn't local storage. It's a directory mounted via NFS (Network File System) from a central file server. The upstream process often moves (renames) files into place, expecting your Node.js app to pick up on this rename event and process the file.

What actually happens? Nothing. Or rather, you might get a change event if the file content itself is modified after the rename. You might even see a delete event for the old name and a create for the new name if the rename is perceived as such by the underlying OS/NFS stack. But the clean, explicit rename event that fs.watch should provide for a path change? Gone. Poof. Like a ghost in the machine.

The Environments Where This Abomination Triggers

This isn't a universal problem, which makes it even harder to debug. It's a nasty cocktail of kernel version, Node.js implementation specifics, and NFS quirks. Here's where we've consistently seen this particular flavor of hell:

Operating System Kernel Version Node.js Version Range NFS Client Version NFS Server Version
Debian 10 (Buster) 4.19.0-x-amd64 14.x.x - 16.x.x NFSv4.0, NFSv4.1 NFSv4.0, NFSv4.1
Ubuntu 20.04 LTS 5.4.0-x-generic 12.x.x - 16.x.x NFSv4.0, NFSv4.1 NFSv4.0, NFSv4.1
CentOS 7 3.10.0-x-el7 10.x.x - 14.x.x NFSv3, NFSv4.0 NFSv3, NFSv4.0

The Futile Attempts (Don't Bother)

Before we get to the fix, let's acknowledge the hours you’ve wasted. You've probably tried increasing inotify watches limits (fs.inotify.max_user_watches). You've likely played with persistent: true and recursive: true options in fs.watch. You might even have thrown another file watching library at it (chokidar, anyone? It usually just wraps fs.watch or fs.watchFile anyway). All for naught. The fundamental issue lies deeper.

This problem highlights why understanding the underlying infrastructure is paramount, especially when dealing with the unfiltered reality of distributed systems in FAANG-like environments. Abstractions are great, but they often hide crucial details that can break your application.

The Root Cause

Here’s the brutal truth: fs.watch in Node.js, on Linux, largely relies on the kernel’s inotify API. inotify is brilliant for local file systems. It directly hooks into VFS (Virtual File System) operations. However, inotify events generally do not traverse NFS boundaries in a consistent, reliable manner for all event types. Specifically, a rename operation performed on the NFS server, or by another client accessing the same NFS share, is often observed by the client running fs.watch as a sequence of a DELETE event for the old path and a CREATE event for the new path, or sometimes just a DELETE on the watched path if the file moves out of the watched directory, and no event for its reappearance at a new name within the same watched directory. The direct IN_MOVED_FROM and IN_MOVED_TO events, which fs.watch would ideally interpret as a single rename event, are simply not reliably propagated or correlated across the NFS protocol.

This is a fundamental impedance mismatch between how local kernel event monitoring works and how a distributed file system like NFS reports changes. NFS is designed for data consistency, not necessarily for real-time, granular event propagation on the client side for all possible operations originating remotely. It's a classic case of angelic hype meeting demonic gotchas when you assume too much about network transparency.

An abstract network diagram showing data flowing through multiple servers and a single broken link highlighted with a red X
Visual representation

The Ugly, Yet Necessary, Solution

Since inotify won’t reliably tell us a file was renamed, we have to become smarter – or rather, more persistent. The workaround involves giving up on instantaneous rename events and instead monitoring for changes that imply a rename, then verifying it with a manual directory scan. This is inefficient, yes. It's a poll-and-compare pattern, which is exactly what fs.watch tries to avoid. But it's robust when you're desperate.

Step-by-Step Fix: The Application-Level Workaround

  1. Accept Imperfection: Your fs.watch listener will still fire for create, change, and delete events. Leverage these.
  2. Maintain State: Keep an in-memory map or set of filenames (or even full file stats like mtime) for the watched directory. Update this map periodically, or whenever any fs.watch event fires.
  3. On Event/Interval: When your fs.watch callback fires (for any type) or on a regular, short interval (e.g., every 500ms-1s), perform a manual fs.readdir on the watched directory.
  4. Compare and Diff: Compare the current directory listing (and optionally, file stats) against your last known state.
    • If a file exists in your old state but not in the new, it was deleted.
    • If a file exists in the new state but not in the old, it was created.
    • If a file exists in both, but its name changed (or its ino changed, or other metadata), this is your potential rename. This is the tricky part.
  5. The Rename Heuristic: To detect a rename, you'll need to look for a "delete" of an old file *and* a "create" of a new file, where the content or some other unique identifier (if available, like a hash or internal ID) strongly suggests it's the same logical file. A simpler, but less robust, heuristic is to assume a "delete" of file A and "create" of file B in close temporal proximity is a rename, especially if the file sizes match.

This is where it gets ugly. You need to implement your own diffing logic. It’s a resource hog compared to inotify, but it works.


const fs = require('fs');
const path = require('path');

const WATCH_DIR = '/mnt/nfs/data'; // Your NFS mounted directory
const POLL_INTERVAL_MS = 1000; // Check every second if fs.watch is unreliable

let currentFiles = new Map(); // Map of { filename: stats }

async function getDirectoryState(dir) {
    const files = await fs.promises.readdir(dir);
    const newState = new Map();
    for (const file of files) {
        try {
            const filePath = path.join(dir, file);
            const stats = await fs.promises.stat(filePath);
            newState.set(file, stats);
        } catch (error) {
            // File might have been deleted between readdir and stat
            console.warn(`Could not stat file ${file}: ${error.message}`);
        }
    }
    return newState;
}

async function detectChanges() {
    console.log('Performing directory scan and diff...');
    const newState = await getDirectoryState(WATCH_DIR);

    const deleted = new Set();
    const created = new Set();
    const changed = new Set(); // For content changes or stat changes
    const potentiallyRenamedOld = new Set(); // Files that might have been renamed from
    const potentiallyRenamedNew = new Set(); // Files that might have been renamed to

    // Detect deletions and potential renames (from old names)
    for (const [filename, stats] of currentFiles) {
        if (!newState.has(filename)) {
            // File is gone from the new state
            if (stats.isFile()) { // Only consider files for rename
                potentiallyRenamedOld.add({ filename, stats });
            } else {
                deleted.add(filename);
            }
        }
    }

    // Detect creations and potential renames (to new names)
    for (const [filename, stats] of newState) {
        if (!currentFiles.has(filename)) {
            // File is new in the new state
            if (stats.isFile()) { // Only consider files for rename
                potentiallyRenamedNew.add({ filename, stats });
            } else {
                created.add(filename);
            }
        }
    }

    // Now, the rename heuristic:
    // Look for a file in potentiallyRenamedOld that matches a file in potentiallyRenamedNew
    // by inode, or by size+mtime (less reliable), or by hash (most reliable but expensive)
    for (const oldFile of potentiallyRenamedOld) {
        let foundRename = false;
        for (const newFile of potentiallyRenamedNew) {
            // Heuristic 1: Same inode (most reliable for renames within same FS)
            // Note: NFS might assign new inode on rename, especially across directories
            if (oldFile.stats.ino === newFile.stats.ino) {
                console.log(`[RENAME DETECTED] From: ${oldFile.filename} To: ${newFile.filename} (inode match)`);
                // Remove from other sets as it's a rename
                potentiallyRenamedNew.delete(newFile);
                foundRename = true;
                break;
            }
            // Heuristic 2: Same size and mtime (less reliable, could be content change)
            // else if (oldFile.stats.size === newFile.stats.size && oldFile.stats.mtimeMs === newFile.stats.mtimeMs) {
            //     console.log(`[RENAME DETECTED] From: ${oldFile.filename} To: ${newFile.filename} (size+mtime match)`);
            //     potentiallyRenamedNew.delete(newFile);
            //     foundRename = true;
            //     break;
            // }
        }
        if (!foundRename) {
            // If it wasn't renamed, it was truly deleted
            deleted.add(oldFile.filename);
        }
    }

    // Any remaining in potentiallyRenamedNew are true creations
    for (const newFile of potentiallyRenamedNew) {
        created.add(newFile.filename);
    }


    if (deleted.size > 0) console.log('[DELETED]', [...deleted]);
    if (created.size > 0) console.log('[CREATED]', [...created]);
    if (changed.size > 0) console.log('[CHANGED]', [...changed]);


    currentFiles = newState; // Update state for next cycle
}

async function initializeWatcher() {
    console.log(`Initializing watcher for: ${WATCH_DIR}`);
    currentFiles = await getDirectoryState(WATCH_DIR);
    console.log('Initial directory state loaded.');

    // Use fs.watch mainly for triggering scans faster than interval
    fs.watch(WATCH_DIR, { persistent: true, recursive: false }, (eventType, filename) => {
        console.log(`fs.watch event: ${eventType} ${filename}`);
        // Instead of processing event directly, trigger a full scan soon
        // Debounce this to avoid too many scans
        clearTimeout(global.watchDebounceTimer);
        global.watchDebounceTimer = setTimeout(detectChanges, 100);
    });

    // Fallback polling for events missed by fs.watch (especially renames)
    setInterval(detectChanges, POLL_INTERVAL_MS);
}

initializeWatcher().catch(err => {
    console.error("Initialization failed:", err);
    process.exit(1);
});

This code performs a full directory scan, compares it to a cached state, and tries to infer renames. The key here is the inode (stats.ino) property, which should remain consistent for a file even if its name changes on the same filesystem. However, be warned: NFS implementations can sometimes assign new inodes across certain operations or depending on how the rename is actually performed on the server. Test this heavily.

Final Thoughts: It Sucks, But It Works

Yes, it's a colossal pain. It's less efficient. It consumes more CPU and I/O. But it’s the price you pay for relying on fs.watch across a distributed file system like NFS where low-level event propagation isn't guaranteed for all operations. You’re effectively reimplementing a simplified version of what inotify does locally, but at the application layer with a polling mechanism. If your requirements demand absolute real-time, consider moving your processing logic directly onto the NFS server or using a distributed queuing system to signal file changes, rather than relying on client-side filesystem events. Good luck, you'll need it.

Read Next