Fixing ENOSPC File-Watcher Limit Errors in Containers
Your Vite, webpack, nodemon, or tsc --watch process crashes on startup inside a container with Error: ENOSPC: System limit for number of file watchers reached, and the dev server never comes up — a recurring local failure point that hits the moment a new engineer opens a large repo under Docker for the first time.
Despite the message, your disk is not full. ENOSPC here is the kernel's error code for "no space left in the inotify watch table," not "no space left on the filesystem." The dev server tried to register a recursive watch over your source tree, the number of watches exceeded a per-user kernel limit, and inotify_add_watch(2) returned ENOSPC. This guide reproduces the failure, explains why the limit is shared across the host kernel rather than isolated per container, and walks through raising fs.inotify.max_user_watches and trimming watch scope so hot reload stays stable.
Diagnostic
Reproduce the crash first, then read the current limit. The stack trace names the file the watcher failed on, which confirms it is an inotify problem and not an application bug.
#!/usr/bin/env bash
set -euo pipefail
# 1. Reproduce inside the container that crashes
docker compose exec web npm run dev || echo "dev server exited"
# 2. Read the current per-user watch limit (shared with the host kernel)
docker compose exec web cat /proc/sys/fs/inotify/max_user_watches
# 3. Read the companion instance limit
docker compose exec web cat /proc/sys/fs/inotify/max_user_instances
Expected BAD output — the watcher aborts partway through registering the tree, and the limit reads at a low default:
dev server exited
Error: ENOSPC: System limit for number of file watchers reached, watch '/app/src/components/DataTable.tsx'
at FSWatcher. (node:internal/fs/watchers:247:19)
at Object.watch (node:fs:2418:36)
8192
128
The 8192 is fs.inotify.max_user_watches and the 128 is fs.inotify.max_user_instances. A modern front-end repo with node_modules present routinely wants tens of thousands of watches, so 8192 is exhausted before the watcher finishes walking the tree — hence the crash mid-registration on a random source file.
To see how many watches are actually being consumed, count the inotify file descriptors and the watches behind them. Run this on the host (native Linux) or inside the Docker Desktop VM, because the counts are kernel-wide:
#!/usr/bin/env bash
set -euo pipefail
# Total inotify instances open across the whole kernel
find /proc/[0-9]*/fd -lname 'anon_inode:inotify' 2>/dev/null | wc -l
# Watches per process (fdinfo lists one "inotify wd:" line per active watch)
for p in /proc/[0-9]*; do
pid=${p##*/}
count=$(grep -c '^inotify' "$p"/fdinfo/* 2>/dev/null | awk -F: '{s+=$2} END {print s+0}')
[ "$count" -gt 0 ] && printf '%6d watches pid %s\n' "$count" "$pid"
done | sort -rn | head
A single Vite process sitting near the top of that list with a watch count in the thousands, next to a max_user_watches of 8192, is the whole story: the limit is real, it is low, and one watcher is eating most of it.
Root cause
inotify is the Linux kernel subsystem file watchers use. Every directory a recursive watcher monitors costs one watch (a wd, or watch descriptor), and the kernel caps how many watches a single real user ID may hold across the entire system through fs.inotify.max_user_watches. On many distributions and inside the default Docker Desktop VM that ceiling is 8192. A framework like Vite, webpack, or Chokidar walks your project and adds a watch for every directory it wants change notifications on, and with node_modules present a mid-size front-end tree contains tens of thousands of directories. The process blows past 8192 long before it finishes, inotify_add_watch(2) returns ENOSPC, and Node surfaces that as the "System limit for number of file watchers reached" error.
Two properties of this limit make it especially confusing inside containers. First, the count is per real user ID, kernel-wide — not per process and not per container. Containers share the host kernel, so three dev containers all running their watcher as UID 1000 draw from the same pool of 8192 watches. Raising the limit for one and not the others fixes nothing; the ceiling is global. Second, fs.inotify.max_user_watches is not in Docker's namespaced-sysctl allowlist, so you cannot set it with a plain sysctls: entry in a Compose service the way you can with net.* values — Docker rejects it as an unsafe, non-namespaced sysctl. The knob lives in the host kernel and must be turned there (or inside the Docker Desktop VM, which is that host kernel from the container's point of view).
There is a companion limit worth knowing about: fs.inotify.max_user_instances, which caps how many inotify instances (distinct inotify_init file descriptors) a user may open, defaulting to 128. You mostly hit max_user_watches first, but a machine running many watch-heavy tools at once — several dev servers, a language server, a test runner in --watch mode — can exhaust max_user_instances too, producing the same ENOSPC from a different exhaustion. Raise both together.
Resolution
There are two independent levers, and the durable fix uses both: raise the kernel ceiling so a full tree fits, and reduce watch scope so you stop paying for directories that never change. Work them in order — raise first to stop the crash, then trim so the limit is comfortable rather than merely survivable.
- Raise
fs.inotify.max_user_watchesandmax_user_instancespersistently on the host. - Apply the change without a reboot.
- Trim the watcher's scope so it ignores
node_modules,.git, and build output. - Restart the dev server and confirm it comes up clean.
On native Linux, set the values through a sysctl.d drop-in so they survive reboots:
#!/usr/bin/env bash
set -euo pipefail
sudo tee /etc/sysctl.d/99-inotify.conf >/dev/null <<'EOF'
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 1024
EOF
sudo sysctl --system
# Confirm the running kernel picked up the new ceiling
sysctl fs.inotify.max_user_watches fs.inotify.max_user_instances
524288 is the widely used ceiling (it is the value many IDEs recommend) and costs roughly half a kilobyte of kernel memory per watch only for watches actually in use, so the higher cap is a headroom limit, not a fixed allocation. If your workflow is orchestrated with Compose and you want the limit applied automatically when the stack starts — useful inside the Docker Desktop VM, where a host sysctl.d file does not exist — run a privileged one-shot init service that writes the sysctl into the shared kernel before the app starts:
# docker-compose.yml
services:
inotify-init:
image: busybox:1.36
command: ["sh", "-c", "sysctl -w fs.inotify.max_user_watches=524288 fs.inotify.max_user_instances=1024"]
privileged: true
network_mode: none
restart: "no"
web:
build: .
depends_on:
inotify-init:
condition: service_completed_successfully
environment:
CHOKIDAR_USEPOLLING: "false"
volumes:
- ./src:/app/src:cached
command: npm run dev
ports:
- "5173:5173"
Because inotify-init is privileged and shares the host kernel, its sysctl -w sets the global value; condition: service_completed_successfully holds web until that has happened, so the dev server never starts against the old 8192 ceiling. Setting CHOKIDAR_USEPOLLING explicitly to "false" documents that you are relying on native inotify events, not CPU-burning polling — polling is a last-resort fallback covered in fixing hot reload not triggering on file changes, and it should not be your default.
Now cut the demand side. The single biggest win is to stop watching node_modules, which as the chart showed is where most of the watches go and which almost never changes during a dev session. Configure the ignore list in your build tool:
// vite.config.js
import { defineConfig } from 'vite'
export default defineConfig({
server: {
watch: {
// Skip the directories that dominate the watch count
ignored: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/coverage/**'],
},
},
})
The equivalent for a raw Chokidar or webpack watcher is a watchOptions.ignored regex or glob; the principle is identical. With node_modules excluded, a source tree that wanted 45,000 watches drops to under a thousand, which fits inside even the old 8192 default — meaning the ignore config alone often resolves the crash, and the raised ceiling becomes headroom rather than the load-bearing fix.
Expected output
After the drop-in is applied and the watcher ignores node_modules, the limit reads high and the dev server starts cleanly:
$ sysctl fs.inotify.max_user_watches
fs.inotify.max_user_watches = 524288
$ docker compose exec web cat /proc/sys/fs/inotify/max_user_watches
524288
$ docker compose up web
VITE v5.4.0 ready in 412 ms
➜ Local: http://localhost:5173/
➜ Network: http://172.20.0.4:5173/
The container reads the same 524288 the host reports, which confirms the sysctl is global and that the value crossed into the container as expected. The dev server now completes registration and prints its ready banner instead of aborting on a source file. Save a file under src/ and the browser should hot-reload within a few hundred milliseconds — proof that the watches were successfully registered rather than silently dropped.
If the container still reports 8192 after all of this, the init service did not run in privileged mode or the host you set the drop-in on is not the host the container's kernel belongs to (the common Docker Desktop trap — see the platform notes below).
Prevention
- Bake the sysctl into machine provisioning rather than fixing it per developer. Ship the
/etc/sysctl.d/99-inotify.confdrop-in through your cloud-init, Ansible, or dotfiles bootstrap so every workstation and CI runner starts with a sane ceiling, and add the same assertion to your onboarding health-check script so a low limit fails setup loudly on day one instead of surfacing as a mysterious dev-server crash later. - Commit the watcher ignore config. A checked-in
server.watch.ignored(orwatchOptions.ignored) list means the demand side is fixed for everyone regardless of their kernel limit, and keeps a laptop with a conservative default from crashing on the same repo that works on a beefier machine. - Assert the threshold in CI or a pre-start check. A one-line guard —
test "$(cat /proc/sys/fs/inotify/max_user_watches)" -ge 65536— in your container entrypoint turns a silent misconfiguration into an explicit, early failure, which is far cheaper to debug than anENOSPCdeep in a watcher stack trace. This is the same fail-fast discipline behind catching missing env vars before container startup.
Platform caveats
macOS (Docker Desktop): the macOS host itself uses
kqueue, not inotify, so a source watcher running natively on the Mac never hits this limit. The moment your watcher runs inside a Linux container, it uses the LinuxKit VM's kernel, andfs.inotify.max_user_watchesthere defaults low. Set it inside the VM — the privilegedinotify-initservice above is the portable way, since a hostsysctl.dfile has no effect on the VM and the value resets when the VM restarts unless something reapplies it.
WSL2: a drop-in under
/etc/sysctl.d/is not guaranteed to apply on boot unless the distro runs systemd. Enable it by adding[boot]withsystemd=trueto/etc/wsl.confand restarting withwsl --shutdown, or set the value from your shell profile. Note that the WSL2 kernel is shared across every distro and every Docker container running under WSL2, so the limit is global to the whole WSL2 instance.
Apple Silicon (ARM64): inotify behaves identically on
arm64; there is no architecture-specific watch limit. If a watcher still misses events under emulation, that is a file-event propagation issue across the virtualization boundary, not anENOSPC— do not raise the watch limit expecting it to fix missed reloads.
Rollback
#!/usr/bin/env bash
set -euo pipefail
sudo rm -f /etc/sysctl.d/99-inotify.conf && sudo sysctl --system # restore distro defaults
Removing the drop-in and reloading returns fs.inotify.max_user_watches to whatever your distribution ships. Because the ceiling is only an upper bound on watches that can be held, lowering it back does not free memory that was never allocated — it simply reinstates the cap, so do this only if the raised limit is implicated in a genuine regression, which is rare.
Frequently Asked Questions
Why does ENOSPC mean file watchers when it usually means disk full?
ENOSPC is a generic POSIX error code meaning "no space left" for whatever resource a syscall was operating on. inotify_add_watch(2) reuses it to signal that the per-user inotify watch table is full, so Node reports it as "System limit for number of file watchers reached." Your filesystem is fine — run df -h to confirm — the exhausted resource is the kernel watch table governed by fs.inotify.max_user_watches, not disk blocks.
Can I raise the limit with a sysctls: entry in my Compose service?
No. fs.inotify.max_user_watches is not a namespaced sysctl, so Docker refuses it in a service's sysctls: list as unsafe. It lives in the host kernel and must be set there — through an /etc/sysctl.d drop-in on native Linux, or via a privileged one-shot init service that runs sysctl -w against the shared kernel, which is the portable option inside the Docker Desktop VM.
Do I need to raise the limit at all if I ignore node_modules?
Often not. node_modules is where the overwhelming majority of watches go, so excluding it in your watcher's ignored config commonly drops a tree from tens of thousands of watches to under a thousand, which fits inside even the old 8192 default. Raising the ceiling is still worth doing as headroom for monorepos and multiple concurrent watchers, but the ignore list alone frequently clears the crash.
Is the watch limit per container or shared across the host?
Shared. inotify watches are counted per real user ID across the entire host kernel, and containers share that kernel, so several dev containers running their watcher as the same UID all draw from one pool. Raising the limit for a single container does nothing; you must raise it on the host kernel (or the Docker Desktop VM) where the count is actually enforced.