Article View

Scroll down to read the full article.

The Silent Killer: Node.js fs.watch Starvation in Docker on Older Kernels

calendar_month August 06, 2026 |
Quick Summary: Diagnose and fix Node.js fs.watch event starvation in Docker containers on CentOS 7/Ubuntu 16.04 with older Linux kernels and cgroup v1 memory lim...

Alright, listen up. If you've ever wrestled with Node.js applications that just... stop reacting to file changes, especially inside Docker containers with strict memory limits, you know the soul-crushing despair. Your hot-reloader dies. Your config watcher goes dormant. Your entire setup feels like it's taking a nap in a critical moment. You've checked everything: file permissions, paths, ulimit for open files. Still, nothing. This isn't your typical "oops, I forgot to bind mount" issue. This is deeper. This is a subtle, insidious interaction between Node.js's underlying inotify usage, older Linux kernels, and cgroup v1 memory limits. We lost weeks to this absolute nightmare. Here's how we finally stomped it out.

The Symptoms: What You See (or Don't See)

  • Your Node.js app, using fs.watch (or libraries like Chokidar, Nodemon, webpack-dev-server which depend on it), stops receiving file change events.
  • No errors logged by Node.js itself. The watcher just silently starves.
  • File changes are happening on the host or inside the container, but your application remains oblivious.
  • Restarting the container temporarily fixes it, only for the problem to resurface hours or days later, seemingly at random.
  • The issue predominantly affects containers with strict memory limits, especially when under load or during periods of high file system activity.

Our Painful Journey

We tore our hair out. We blamed Node.js versions, specific libraries, even Docker Desktop's arcane internal networking. We dove into strace output, looking for failed inotify calls, but they mostly showed successful watcher registration, just no events following. We checked lsof for open file handles, sysctl -a for kernel parameters, and scoured dmesg for OOM events or memory pressure warnings. Nothing immediately screamed "fix me." The kernel was telling Node.js "okay, here's your watcher," but then just... not sending events. It felt like shouting into a void. It was only after a grueling deep dive into kernel logs, cgroup metrics, and a lot of coffee that the pieces started to click.

Environments Where This Scourge Strikes

This particular beast thrives in a specific, somewhat outdated, yet still common combination of technologies. If your stack looks like this, pay close attention:

Operating System Kernel Version Range Container Runtime Node.js Version(s) Cgroup Version
CentOS 7.x / RHEL 7.x 3.10.0-693.el7.x86_64 to 3.10.0-1160.x.el7.x86_64 Docker Engine (all versions) 14.x, 16.x, 18.x (LTS) cgroup v1
Ubuntu 16.04/18.04 4.4.x to 4.15.x Docker Engine (all versions) 14.x, 16.x, 18.x (LTS) cgroup v1
A complex
Visual representation

The Root Cause

Here's the ugly truth: Linux's inotify subsystem, which Node.js's fs.watch relies on, uses a small, fixed-size kernel buffer to queue events. This buffer is managed within kernel memory. When this buffer fills up, typically due to a rapid succession of file changes (an "event storm") or simply too many watched files generating events simultaneously, it silently drops new events. By default, this buffer is pretty small. On older kernels (pre-4.16, though issues persist on some 4.x branches), and especially within cgroup v1 memory-constrained Docker containers, this buffer's memory consumption is counted against the container's memory limit in a particularly nasty way.

Cgroup v1's memory accounting for kernel memory is less granular than cgroup v2. When a container approaches its memory limit, the kernel gets aggressive. It starts reclaiming memory from various sources. The inotify event queue, despite being critical for your app's functionality, can be seen as reclaimable kernel memory. The kernel doesn't just stop queuing; it can effectively starve the queue by discarding events without notifying the application. Your container isn't necessarily OOM-killed, but its ability to receive file events is crippled. This is particularly problematic in environments with many watched files or frequent changes, leading to an "event storm" that overflows the buffer, or simply a situation where the kernel aggressively reclaims memory during periods of overall system pressure.

It's a subtle resource contention. We've seen similar obscure resource contention issues, like the Node.js DNS failures in Nomad/Consul on CentOS 7, which often require digging into specific kernel parameters or runtime configurations. Check out "Solving the Obscure: Node.js 18's Intermittent DNS Failures in Nomad/Consul on CentOS 7" for another example of this frustrating class of problems.

The Fix: Stop the Starvation

The solution involves two parts: increasing the inotify buffer size and, more importantly, ensuring your container has enough memory headroom so the kernel isn't forced to aggressively reclaim critical kernel memory used by inotify. Directly manipulating memory.kmem.limit_in_bytes is the ideal, but often unavailable, fix for Docker on older cgroup v1 kernels.

Step 1: Increase inotify User Limits (on the Host)

First, ensure your host has sufficient inotify limits. While not the direct cause of starvation, low limits can exacerbate the problem, especially max_queued_events, which dictates the size of the kernel's event queue. You might already have these set, but double-check:


sudo sysctl -w fs.inotify.max_user_watches=524288
sudo sysctl -w fs.inotify.max_queued_events=1048576
sudo sysctl -w fs.inotify.max_user_instances=1280

For persistence, add these lines to /etc/sysctl.conf or create a new file like /etc/sysctl.d/99-inotify.conf containing them, then run sudo sysctl --system.

Step 2: Increase Cgroup Memory Headroom (The Crucial Part for Docker)

This is where it gets tricky for cgroup v1. Ideally, you'd configure memory.kmem.limit_in_bytes for your container's cgroup to explicitly set a limit for kernel memory, which includes inotify buffers. However, many enterprise Linux kernels (like CentOS 7's 3.10.x series) either don't have CONFIG_MEMCG_KMEM enabled or Docker's integration with it is incomplete or difficult to manage directly through standard Docker commands. If you are managing cgroups manually or via a different orchestrator like Nomad that exposes these controls, you might use something like:


# This command is executed on the HOST for the container's cgroup
# NOTE: This requires kernel `CONFIG_MEMCG_KMEM` which is often missing or disabled
# in older kernels (e.g., RHEL/CentOS 7 kernels). If 'kmem' is not available,
# proceed to the `--memory` flag alternative below.

sudo sh -c 'echo "1G" > /sys/fs/cgroup/memory/docker/<container_id>/memory.kmem.limit_in_bytes'
# Replace "1G" with a suitable value (e.g., 512M, 2G). Start with 512M if available.
# Replace <container_id> with your actual Docker container ID.

IF memory.kmem.limit_in_bytes is NOT available or configurable via Docker (which is common for the environments specified), the most reliable workaround is to significantly increase the Docker container's assigned memory limit using --memory in docker run or memory: in Docker Compose. This reduces the general pressure on the kernel's memory management for that cgroup, making it far less likely to reclaim `inotify` buffers under memory contention.


# Example Docker run command for the container
# Significantly increasing the memory limit to reduce kernel memory pressure.
# This assumes the host has ample physical memory.

docker run -d \
  --name my-node-app \
  --memory="2g" \  <-- INCREASE THIS VALUE SIGNIFICANTLY
  --cpus="1" \
  -p 3000:3000 \
  my-node-image:latest

For situations like this, where memory management becomes critical for subtle processes, ensuring your deployment environment is robust enough to handle unexpected resource spikes is paramount. Thinking about high-volume data workflows, it's a bit like making sure your n8n setup is "bulletproof" against resource contention. We learned a lot from architecting solutions like those described in "Unleash the Kraken: Architecting a Bulletproof n8n Workflow for High-Volume Data" – sometimes the answer isn't a new feature, but deeper resource provisioning and kernel tuning.

Verification

After applying these changes, monitor your application. The fs.watch events should now flow reliably. Pay attention to dmesg output for any new memory-related warnings, and watch slabtop on the host to observe kernel memory usage, especially for inotify related entries. You should see an increase in inotify event count if it was previously starved, and your application should react to file changes immediately without inexplicable delays.

A complex
Visual representation

Why This Matters

This isn't just about a broken hot-reloader or a minor inconvenience. In production, fs.watch is often used for critical config reloading, certificate rotation, or monitoring changes in data directories for processing pipelines. A silent failure here can lead to stale configurations, security vulnerabilities (un-rotated certs leading to expired connections), or data processing halts for critical services. These are the kinds of obscure, "long-tail" issues that can bring down an otherwise robust system, highlighting why deep understanding of the kernel and container runtimes is non-negotiable for any veteran SRE.

Don't let silent failures sneak up on you. Dig deep, question assumptions, and never trust a "works on my machine" when containers, cgroups, and older kernels are involved. Your sanity, and your uptime, depend on it.

Discussion

Comments

Read Next