Article View

Scroll down to read the full article.

Node.js fs.watch Deadlock: The NFSv3/FUSE Hang on Ancient Kernels (A Battle-Scarred SRE's Retrospective)

calendar_month August 29, 2026 |
Quick Summary: Unravel the obscure Node.js fs.watch deadlock when interacting with NFSv3 or FUSE mounts on older Linux kernels. Fix rapid remote directory change...

Alright, listen up. If you've ever stared at a Node.js process pegged at 100% CPU, completely silent, with no error logs, only to find it's pointed at some archaic NFSv3 share or a janky FUSE mount on an older kernel, you know the soul-crushing despair. This isn't your average memory leak. This isn't an unhandled promise rejection. This is a deeper, more insidious beast: the Node.js fs.watch deadlock, and it's a hell you wouldn't wish on your worst enemy.

We've all been there. Your service, previously humming along, suddenly becomes a zombie. It's consuming all available CPU, but doing absolutely nothing useful. No requests are processed. Your health checks fail. A quick restart temporarily brings it back, only for it to fall victim to the same silent killer hours later, often correlated with bursts of file changes on a mounted filesystem.

The Symptoms:

  • Node.js process running at 100% CPU, indefinitely.
  • Application logs go completely silent; no errors, no output.
  • strace -p <PID> shows continuous calls to epoll_wait or read on an inotify file descriptor, but no actual progress.
  • This occurs exclusively when fs.watch (or libraries like Chokidar, which use it) is monitoring paths residing on NFSv3 or FUSE mounts.
  • The issue often triggers after a period of rapid file system activity (creates, deletes, renames) on the mounted share.

Tangled knots of optical fibers leading to a rusted server rack
Visual representation

The Environment Trap:

This isn't a universal flaw, which makes it even harder to diagnose. It's a specific, ugly interaction. Here's where we've seen it rear its ugly head:

Operating System Kernel Version Range Node.js Version Range Filesystem Type
CentOS 7.x, Ubuntu 16.04/18.04 4.x (specifically 4.4 to 4.14) 12.x, 14.x NFSv3, FUSE (e.g., S3FS)
Debian 9/10 4.x (specifically 4.9 to 4.19) 12.x, 14.x NFSv3, FUSE
Alpine Linux (Docker) Host Kernel 4.x 12.x, 14.x (Node-Alpine images) NFSv3 (mounted via host), FUSE

Initial Misdirections (and why they failed):

Naturally, we chased our tails. "It's inotify limits!" No, we cranked fs.inotify.max_user_watches and max_queued_events to absurd levels. Didn't matter. The system wasn't hitting limits; it was just stuck. We've seen similar issues with general inotify black holes in Docker, a topic we actually covered in Node.js fs.watch Hell: The inotify Black Hole in Docker on Older Kernels, but this was different. This wasn't about missing events; it was about a stuck event loop.

"It's a memory leak!" Nope. Heap profiles were clean. No growing RSS. Just a busy CPU doing nothing.

"Our code is bugged!" We stripped the application down to a simple fs.watch('/mnt/nfs_share', { recursive: true }, (eventType, filename) => { console.log(eventType, filename); });. Same result. The problem wasn't our logic; it was lower level.

The Root Cause

Here's the brutal truth: Node.js's fs.watch, powered by libuv, relies heavily on Linux's inotify mechanism. On older kernels, particularly those in the 4.x series, the interaction between inotify, the Virtual File System (VFS) layer, and specific networked filesystems like NFSv3 or FUSE becomes... brittle. When rapid, concurrent file operations (especially renames or deletes of directories) occur on these remote mounts, inotify can sometimes emit event sequences that confuse libuv's internal state machine. Specifically, we observed scenarios where an IN_IGNORED event for a directory was either delayed, not sent at all, or sent prematurely, while the kernel's underlying VFS still held a reference to the 'watched' inode. Libuv's watcher would then enter a busy-wait state, continuously polling an inotify descriptor that was effectively dead for that specific path or waiting for an event (like IN_DELETE_SELF) that would never arrive in the expected sequence. This leads to an endless loop of epoll_wait yielding immediately with a non-fatal error or no events, causing 100% CPU consumption as the Node.js event loop spins uselessly, never yielding control back for actual application logic.

A ghostly specter of a process ID hovering over a timeline graph with a sharp spike in CPU
Visual representation

The Ugly Truth (and the Fix):

There isn't a clean, upstream patch for these specific kernel/NFSv3/Node.js combinations. The real fix involves upgrading your kernel to a much newer version (5.x or later) or upgrading your NFS protocol to v4.x, or ditching FUSE for more robust options. But when you're stuck in production with legacy constraints, you need a workaround. This isn't elegant, but it works.

The solution is to force Node.js to use a simpler, albeit less efficient, stat() polling mechanism for problematic paths, bypassing the faulty inotify interaction. Node.js (via libuv) has an internal fallback, but it's not always triggered reliably in these deadlock scenarios.

We found that leveraging a little-known experimental flag forces this polling behavior for fs.watch:

NODE_OPTIONS="--experimental-fs-watch-polling-fallback" node your-app.js

This tells Node.js to lean on periodic stat() calls instead of relying solely on the kernel's inotify events for file system watching. It's a trade-off: higher CPU utilization due to more frequent polling, but infinitely better than a complete deadlock. You need to weigh the performance implications, much like we do when considering different AI models in environments with strict resource constraints, as discussed in Llama 3 8B: The Brutal Truth of Deploying Open-Source AI (And Why You Still Should). Sometimes, predictability trumps raw efficiency.

Step-by-Step Resolution:

  1. Identify Problematic Services: Pinpoint any Node.js applications that are experiencing the 100% CPU deadlock and are using fs.watch on NFSv3 or FUSE mounts.
  2. Apply the Environment Variable: Modify your service's startup script or container definition to include the NODE_OPTIONS flag. For Docker environments, this might look like adding -e NODE_OPTIONS="--experimental-fs-watch-polling-fallback" to your docker run command or in your docker-compose.yml.
  3. Test Thoroughly: Deploy the change to a staging environment and simulate the rapid file system activity that typically triggers the deadlock. Monitor CPU usage and application responsiveness.
  4. Monitor in Production: After successful staging tests, deploy to production. Keep a close eye on CPU utilization for the affected services. Expect a slight increase due to polling, but confirm the deadlocks are gone.
  5. Long-Term Strategy: Start planning for infrastructure upgrades. Migrate away from NFSv3 to NFSv4 or later. Evaluate replacing FUSE mounts with more native or resilient storage solutions. Upgrade your Linux kernel to a modern stable release (5.x or higher) that has robust inotify implementations and better NFS client stability.

This fix is a band-aid, a necessary evil to keep legacy systems limping along. But it will prevent your Node.js services from silently committing suicide on those cursed filesystem mounts. Good luck, you'll need it.

Discussion

Comments

Read Next