Start a vite dev server on Linux and the client half dies before it renders anything:
Error: ENOSPC: System limit for number of file watchers reached, watch '/home/juan/Desktop/Juan/Projects/git-proxy/vite.config.ts'
at FSWatcher.<computed> (node:internal/fs/watchers:321:19)
at Object.watch (node:fs:2548:36)
at createFsWatchInstance (file:///home/juan/Desktop/Juan/Projects/git-proxy/node_modules/vite/dist/node/chunks/node.js:9429:16)
errno: -28,
syscall: 'watch',
code: 'ENOSPC',
ENOSPC normally means “no space left on device”, so the natural first move is df -h, and the disk turns out to be fine. Nothing is wrong with your storage. The call underneath is fs.watch, which Node implements with inotify on Linux, and inotify_add_watch returns ENOSPC when the user’s limit on watches is reached. So the code is accurate as far as the kernel is concerned; it is just the nearest errno to hand rather than something that names the actual resource.
There is a useful tell in that message. The file it failed on is vite.config.ts, which is the very first thing vite watches. It never reached your source at all, which means this is not a problem with your project: it will happen on every branch, and on the next vite or webpack project you open today. I hit it while checking that dev mode still worked after moving where GitProxy’s UI build lands, spent a moment suspecting my own change, then reproduced it on main and stopped worrying.
Two limits, and both are per-user
inotify has two separate ceilings, and it is worth knowing which one you hit:
cat /proc/sys/fs/inotify/max_user_watches
cat /proc/sys/fs/inotify/max_user_instances
max_user_watches is how many individual files and directories can be watched. max_user_instances is how many separate inotify handles can exist. Both are limits per real user ID rather than per process, which is the part that catches people out: your editor’s file watcher, your language server and your dev server all draw from the same pool, so nothing you do inside one project explains the failure on its own.
max_user_instances starts at 128, which is the value the kernel assigns to the initial user namespace, and it is very often the real culprit rather than the watch count. Every VS Code window, every language server, every tsx watcher, every Docker daemon helper and every concurrently child takes its own instance. If you keep a few projects open at once, you can exhaust 128 instances while sitting nowhere near the watch limit, which is why bumping only max_user_watches sometimes appears to do nothing.
Finding what is holding them
Every inotify handle is a file descriptor, so the processes responsible are visible in /proc:
sudo find /proc/[0-9]*/fdinfo -type f 2>/dev/null \
| xargs -I{} sh -c 'c=$(grep -c "^inotify" {} 2>/dev/null); [ "${c:-0}" -gt 0 ] && echo "$c {}"' 2>/dev/null \
| sort -rn | head -15
Each line is a count of inotify descriptors and the /proc/<pid>/fdinfo/<fd> path they came from. Pull the PID out of the path and name it with ps -p <pid> -o comm=. In my case the top of the list was code and a handful of node processes, which is the usual shape; a desktop file indexer or gvfs often shows up too.
The fix: raise both, and persist it
sudo tee /etc/sysctl.d/60-inotify.conf > /dev/null <<'EOF'
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 1024
EOF
sudo sysctl --system
Putting it in /etc/sysctl.d/ is what makes it survive a reboot, unlike a bare sysctl -w. No reboot is needed now, but you do have to restart vite, because the process that failed does not retry the watch.
The cost is modest and bounded. Both knobs exist to limit how much kernel memory inotify can consume, and a watch costs roughly a kilobyte of it, unswappable, and only while the watch is actually held. So 524288 is a ceiling rather than an allocation. This is the standard recommendation for anyone doing frontend work on Linux, and it is a little unkind of the distros that ship the conservative default to the exact audience most likely to hit it.
While you are looking at that output: ignore vite’s second suggestion
The same failed run printed something else worth reading, just above the crash:
(!) Your Vite config uses features that are unsupported by `configLoader: 'native'`, which is planned to become the default in a future major version of Vite:
- ESM syntax in a file loaded as CommonJS (vite.config.ts:17:1). Use a `.mjs` extension or set `"type": "module"` in the closest package.json
Vite offers two remedies there. Take the rename, not "type": "module". That field is not scoped to the config file: it is what decides the module system for every .js in the package, and with a TypeScript setup emitting CommonJS it flips the whole compiled output to ESM. Anything in that output relying on __dirname then breaks, which for me would have taken out the helper resolving the UI build path in the packaging work above. Rename to vite.config.mts and update whatever passes --config, or set VITE_CONFIG_NATIVE_IGNORE_WARNING=true and deal with it when configLoader: 'native' actually becomes the default.
The takeaway
ENOSPC from a file watcher is a kernel limit, not a disk problem, and it belongs to your user rather than to your project, so the fix is a sysctl and not a code change. If the watcher fails on the config file itself, skip straight past your own source. And check instances as well as watches, because 128 is a small number once you count every editor window and dev server you have running.