Current Behavior
The daemon registers two independent native watchers over the same workspace root: the source watcher (packages/nx/src/daemon/server/watcher.ts, watchWorkspace) and the outputs watcher (watchOutputFiles). Each one is a separate Watcher instance with its own kernel registration.
On Windows (and macOS) every file operation under a watched tree pays for each matching registration: the kernel builds the notification, copies it into that registration's buffer and completes its I/O. Measured with a plain fs.watch(dir, { recursive: true }) that does nothing with the events, creating 300 1 KB files per round, interleaved with an unwatched control directory on the same volume, medians over 4 rounds:
| idle recursive watchers on the tree |
creates/s (watched) |
creates/s (control) |
ratio |
| 0 |
4,178 |
4,170 |
1.00 |
| 1 |
2,711 |
4,237 |
0.64 |
| 4 |
2,436 |
4,220 |
0.58 |
| 16 |
1,953 |
4,110 |
0.48 |
So two registrations put the floor at roughly 0.6x before the daemon does any work. With the daemon running, the same benchmark inside the workspace root measures 0.32x of the volume-root rate; with nx daemon --stop it measures 1.04x. Ruled out as the cause: Defender (an exact exclusion on the benchmark directory changes nothing), project-graph recomputation (a gitignored out/ path is taxed identically), plugin-worker respawn (NX_ISOLATE_PLUGINS=false changes nothing).
Every editor save, git checkout, build output write and test run in the workspace pays this.
Environment for the numbers: Windows 11 Pro 26100, ReFS Dev Drive (NTFS shows the same per-watcher ratio), native watcher with a single recursive registration per Watcher (the macOS code path applied to Windows — see the linked PR).
Expected Behavior
One kernel registration per workspace. Both consumers can be fed from a single event stream: the outputs watcher only needs events under known output paths and uses useIgnore = false, so a single watcher with useIgnore = false plus a userland split (gitignore-filtered stream → workspace changes; output-path-filtered stream → outputs tracking) gives both what they have today at half the kernel cost.
Related, same file: packages/nx/src/daemon/client/daemon-environment.js strips NX_NATIVE_LOGGING from the client env (DAEMON_ENV_VARS_EXCLUSIONS) and then forces NX_NATIVE_LOGGING=nx=debug as the daemon default (DAEMON_ENV_OVERRIDABLE_SETTINGS). The native watcher logs one DEBUG line per event per watcher, so every filesystem event in the workspace writes two lines to daemon.log and nothing the user sets can turn it off (daemon.log reached 526 MB on this workspace). Defaulting to nx=error, or honoring the client value, is a one-line change.
GitHub Repo
No response
Steps to Reproduce
-
Any workspace on Windows or macOS with the daemon running.
-
Create files in a loop inside the workspace root and in a directory outside it on the same volume, interleaved, and compare rates:
// node ratio.mjs "repo=D:/ws/bench" "root=D:/bench"
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
const N = 300, ROUNDS = 7, buf = Buffer.alloc(1024, 0x61)
const paths = process.argv.slice(2).map(a => { const [label, dir] = a.split('='); return { label, dir, rates: [] } })
const median = a => [...a].sort((x, y) => x - y)[a.length >> 1]
for (let k = 0; k < ROUNDS; k++)
for (const p of paths) {
rmSync(p.dir, { recursive: true, force: true }); mkdirSync(p.dir, { recursive: true })
const t = performance.now()
for (let i = 0; i < N; i++) writeFileSync(`${p.dir}/f${i}.ts`, buf)
p.rates.push(N / ((performance.now() - t) / 1000))
rmSync(p.dir, { recursive: true, force: true })
}
const ref = median(paths.at(-1).rates)
for (const p of paths) console.log(p.label, Math.round(median(p.rates)), '/s', (median(p.rates) / ref).toFixed(2))
-
nx daemon --stop, repeat step 2.
-
Optionally spawn N node -e "require('fs').watch(process.argv[1], {recursive:true}, ()=>{}); setInterval(()=>{}, 1e6)" <dir> processes on a fresh directory and repeat to see the per-registration cost in isolation.
Nx Report
Node : 26.5.1
OS : win32-x64
Native Target : x86_64-windows
pnpm : 11.21.0
daemon : Available
nx : 23.1.1
@nx/js : 23.1.1
@nx/workspace : 23.1.1
@nx/devkit : 23.1.1
@nx/vitest : 23.1.1
typescript : 6.0.3
Failure Logs
No response
Package Manager Version
pnpm 11.21.0
Operating System
Additional Information
Related:
The forced NX_NATIVE_LOGGING=nx=debug default has no existing report.
Investigated, measured and written by Claude Fable 5 (Anthropic) running in Claude Code, on behalf of and reviewed by @christopher-buss.
Current Behavior
The daemon registers two independent native watchers over the same workspace root: the source watcher (
packages/nx/src/daemon/server/watcher.ts,watchWorkspace) and the outputs watcher (watchOutputFiles). Each one is a separateWatcherinstance with its own kernel registration.On Windows (and macOS) every file operation under a watched tree pays for each matching registration: the kernel builds the notification, copies it into that registration's buffer and completes its I/O. Measured with a plain
fs.watch(dir, { recursive: true })that does nothing with the events, creating 300 1 KB files per round, interleaved with an unwatched control directory on the same volume, medians over 4 rounds:So two registrations put the floor at roughly 0.6x before the daemon does any work. With the daemon running, the same benchmark inside the workspace root measures 0.32x of the volume-root rate; with
nx daemon --stopit measures 1.04x. Ruled out as the cause: Defender (an exact exclusion on the benchmark directory changes nothing), project-graph recomputation (a gitignoredout/path is taxed identically), plugin-worker respawn (NX_ISOLATE_PLUGINS=falsechanges nothing).Every editor save,
git checkout, build output write and test run in the workspace pays this.Environment for the numbers: Windows 11 Pro 26100, ReFS Dev Drive (NTFS shows the same per-watcher ratio), native watcher with a single recursive registration per
Watcher(the macOS code path applied to Windows — see the linked PR).Expected Behavior
One kernel registration per workspace. Both consumers can be fed from a single event stream: the outputs watcher only needs events under known output paths and uses
useIgnore = false, so a single watcher withuseIgnore = falseplus a userland split (gitignore-filtered stream → workspace changes; output-path-filtered stream → outputs tracking) gives both what they have today at half the kernel cost.Related, same file:
packages/nx/src/daemon/client/daemon-environment.jsstripsNX_NATIVE_LOGGINGfrom the client env (DAEMON_ENV_VARS_EXCLUSIONS) and then forcesNX_NATIVE_LOGGING=nx=debugas the daemon default (DAEMON_ENV_OVERRIDABLE_SETTINGS). The native watcher logs one DEBUG line per event per watcher, so every filesystem event in the workspace writes two lines todaemon.logand nothing the user sets can turn it off (daemon.logreached 526 MB on this workspace). Defaulting tonx=error, or honoring the client value, is a one-line change.GitHub Repo
No response
Steps to Reproduce
Any workspace on Windows or macOS with the daemon running.
Create files in a loop inside the workspace root and in a directory outside it on the same volume, interleaved, and compare rates:
nx daemon --stop, repeat step 2.Optionally spawn N
node -e "require('fs').watch(process.argv[1], {recursive:true}, ()=>{}); setInterval(()=>{}, 1e6)" <dir>processes on a fresh directory and repeat to see the per-registration cost in isolation.Nx Report
Failure Logs
No response
Package Manager Version
pnpm 11.21.0
Operating System
Additional Information
Related:
HANDLE/memory slope on Windows and asks for output tracking not to add a second workspace-wide watcher. This issue is the same defect measured from the other side: the per-registration delivery cost that every file operation pays, which stays after per-directory registration is gone.Watcher). The numbers above were taken with that change applied; two registrations remain.outputsHashesMatchBatchreturning all-false, so every cache hit re-copies outputs. A single shared registration keeps output tracking.The forced
NX_NATIVE_LOGGING=nx=debugdefault has no existing report.Investigated, measured and written by Claude Fable 5 (Anthropic) running in Claude Code, on behalf of and reviewed by @christopher-buss.