loke.dev
Header image for The inotify Ceiling

The inotify Ceiling

Why your Node.js watch scripts silently fail in massive monorepos and how to audit the Linux VFS resources that govern file system observation.

· 4 min read

I spent three hours once trying to figure out why my Vite dev server stopped hot-reloading. No error messages. No stack traces. Just a silent, stubborn refusal to acknowledge that I’d touched a single line of code. I reinstalled node_modules, I cleared the cache, I even restarted my machine (the ultimate admission of defeat). Nothing. Then I stumbled into the dark world of Linux kernel parameters and realized I wasn't out of RAM or disk space—I was out of "eyes."

If you’re working in a massive monorepo, you’ve likely bumped into the inotify ceiling. It’s the invisible limit that keeps your Linux system from watching too many files at once, and when you hit it, Node.js tools usually just... fail silently.

The Kernel is Watching (But Only a Little)

At the heart of almost every Node.js file-watching utility—whether it's chokidar, nodemon, or the built-in fs.watch—is a Linux kernel subsystem called inotify.

Inotify is great. It lets applications register a "watch" on a file or directory. Instead of the application constantly polling the disk (which is slow and kills batteries), the kernel sends an event notification when something changes.

The problem is that the kernel is a bit of a micromanager. To prevent a single rogue process from hogging all the system's memory for file tracking, it sets a hard cap on how many files any single user can watch.

You can see your current limit by running:

cat /proc/sys/fs/inotify/max_user_watches

On many systems, this defaults to 8192. In 2010, that was plenty. In a 2024 monorepo where a single npm install brings in 40,000 files across 300 packages? 8192 is a joke.

Why Node.js Sucks at This

Node.js file watchers usually work recursively. When you tell a tool to watch ./src, it doesn’t just watch that folder. It sets an inotify watch on every single sub-directory.

If you have a structure like this:

my-monorepo/
├── apps/
│   └── web/ (2,000 directories)
├── packages/
│   ├── ui/ (500 directories)
│   └── utils/ (100 directories)
└── node_modules/ (15,000 directories)

Even if you aren't "watching" node_modules, many poorly configured tools accidentally crawl it or include it in the initial scan. Suddenly, you've used up 17,000 watch handles. The 17,001st file simply won't be watched. The kernel says "No," and Node.js often swallows that error or fails to bubble it up to your terminal.

How to Audit the Damage

Before you go cranking up kernel limits, you should probably see who is eating all your handles. Here is a handy "one-liner" (well, a very long piped command) to see which processes are the greediest:

find /proc/*/fd -lname anon_inode:inotify -printf '%h\n' 2>/dev/null | \
  cut -d/ -f3 | \
  xargs -I{} ps -p {} -o pid,comm,user | \
  sort | uniq -c | sort -nr

This looks into the file descriptors (fd) of every running process, finds the ones linked to inotify, and maps them back to the process name. If you see node or vscode at the top of that list with thousands of hits, you’ve found your culprit.

Raising the Ceiling

If you’ve confirmed you’re hitting the limit, you need to tell the kernel to be more generous. You can do this temporarily to test the fix:

sudo sysctl fs.inotify.max_user_watches=524288
sudo sysctl -p

Why 524288? It’s a common "high" value used by tools like VS Code and IntelliJ. It’s large enough for almost any monorepo but small enough that it won't tank your system memory (each watch takes roughly 1KB on a 64-bit system).

To make this change permanent—so your dev environment doesn't break again after a reboot—add it to your sysctl configuration:

echo "fs.inotify.max_user_watches=524288" | sudo tee -a /etc/sysctl.d/99-inotify.conf
sudo sysctl -p /etc/sysctl.d/99-inotify.conf

Writing Better Watchers

If you're an app developer, you fix this by changing sysctl. If you're a library author, you fix this by being less greedy.

If you're using chokidar (the gold standard for Node file watching), make sure you are aggressively ignoring node_modules and .git folders. Don't rely on the tool to "just know."

const chokidar = require('chokidar');

// Bad: Watching everything
const watcher = chokidar.watch('.');

// Better: Being specific and ignoring the heavy stuff
const watcher = chokidar.watch('./src', {
  ignored: [
    /(^|[\/\\])\../, // ignore dotfiles
    '**/node_modules/**',
    '**/dist/**',
    '**/.git/**'
  ],
  persistent: true
});

watcher.on('add', path => console.log(`File ${path} has been added`));

The "Silent" Part of the Failure

The most annoying part of the inotify limit is that fs.watch in Node.js doesn't always throw an exception when it fails to instantiate a watcher due to ENOSPC (Error: No space left on device—which is the cryptic error code Linux uses for "out of inotify handles").

If you want to be a hero in your own codebase, you can add a check at startup to see if the environment is healthy:

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

function checkInotifyLimits() {
  if (process.platform !== 'linux') return;

  try {
    const limit = fs.readFileSync('/proc/sys/fs/inotify/max_user_watches', 'utf8');
    if (parseInt(limit, 10) < 65536) {
      console.warn(`⚠️  Warning: inotify limit is low (${limit.trim()}).`);
      console.warn(`Hot reloading might fail in large projects.`);
    }
  } catch (e) {
    // If we can't read the file, just move on
  }
}

checkInotifyLimits();

Summary

The next time your dev server feels "ghostly"—where the process is running but nothing is happening—stop checking your code. Check your kernel. The inotify ceiling is a rite of passage for Linux developers, and once you raise it, you can go back to worrying about actual bugs in your code rather than the infrastructure beneath it.