FallbackWatcher: `#unregisterDir` rescans the whole directory registry per unlink, stalling the dev server on Windows
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 5.6k
- Forks
- 696
- Avg merge
- 8m
- Merged PRs (30d)
- 7
Description
Summary
On any platform without Watchman, metro-file-map falls back to FallbackWatcher. Its
#unregisterDir() iterates every key in the directory registry on every unlink-ish
event. In a React Native project whose native build tree sits inside the watched root — the
default for Android, android/app/.cxx — CMake/ninja churn produces tens of thousands of
transient file deletions, so this becomes an O(registry × events) scan that saturates the
event loop.
The user-visible result is not an error. Metro keeps serving, just slowly enough that
anything latency-sensitive breaks. In our case React Native DevTools opened to a permanently
blank window, because the CDP traffic it needs at startup was arriving ~2000 ms per request
instead of single-digit milliseconds.
Environment
metro, metro-file-map, metro-config |
0.87.0 |
react-native |
0.87.1 |
@react-native/dev-middleware |
0.87.1 |
| Node | 22.23.2 |
| OS | Windows 11 |
| Watchman | not installed |
| Architecture | New Architecture enabled (Fabric + TurboModules) |
Symptom
npx react-native startruns and bundles successfully (24.5 MB bundle, no errors).- React Native DevTools (Electron shell, and the same frontend opened manually in Chrome)
shows a blank window — white, grey, then white — and never populates. - The Metro process sits at high CPU while the project is completely idle.
- Simple HTTP requests to the dev server take ~2000 ms.
The blank DevTools window is the misleading part: it looks like a DevTools bug, and there are
open reports that describe the same surface symptom without identifying a cause (see
Possibly related below). It is a latency problem in the dev server.
Root cause
packages/metro-file-map/src/watchers/FallbackWatcher.js:
#unregisterDir(dirpath) {
const removedFiles = [];
for (const registeredDir of Object.keys(this.#dirRegistry)) { // <-- full scan
if (
registeredDir === dirpath ||
registeredDir.startsWith(dirpath + path.sep)
) {
for (const filename of Object.keys(this.#dirRegistry[registeredDir])) {
removedFiles.push(path.join(registeredDir, filename));
}
delete this.#dirRegistry[registeredDir];
}
}
return removedFiles;
}
#dirRegistry is a flat map keyed by absolute directory path, holding every watched
directory in the root (including node_modules). Finding the subtree under dirpath
requires a linear scan with a startsWith per key, so the cost is proportional to the size
of the whole registry — not to the size of the subtree being removed.
It is called from the error path of #normalizeChange:
} catch (error) {
if (!isIgnorableFileError(error)) { ... }
this.#unregister(fullPath);
const removedFiles = this.#unregisterDir(fullPath); // <-- every vanished path
...
}
Any watched path that has already gone by the time it is stated lands here — which is the
normal case for a build tool writing and deleting temporary files. So the full-registry scan
runs once per transient file, not once per directory removal.
Two things make this reliably bad for React Native on Windows:
NativeWatcheris macOS-only, so Windows and Linux always getFallbackWatcher:static isSupported() { return platform() === "darwin"; }android/app/.cxxis inside the watched root and is not excluded by any default
blockList. It is the single largest churn source in a default RN Android project.
Evidence
Node --cpu-prof of the Metro process while the project was idle:
- 88.7% of samples in
#unregisterDir(FallbackWatcher).
Instrumenting the watcher's event emission over one idle two-minute window:
- 62,389 events, all originating from
android/app/.cxx/Debug/<hash>.
After excluding .cxx from the blockList (workaround below), with nothing else changed:
- Dev server response time: ~2000 ms → 7–15 ms.
- DevTools opens and populates normally.
- Metro CPU at idle drops to nil.
Reproduction
- Windows, no Watchman installed.
npx @react-native-community/cli init Repro(RN 0.87.1), no custommetro.config.js.npx react-native startnpx react-native run-android— this createsandroid/app/.cxxwith the CMake/ninja
build tree, inside the watched root.- Leave the project idle. Metro's CPU stays high;
curl -w "%{time_total}"against the dev
server shows seconds-scale responses; DevTools opens blank.
Larger projects hit this harder, since the cost scales with the registry size.
Workaround
Exclude native build output from the watched set. With @rnx-kit/metro-config, note that
makeMetroConfig replaces its own blockList when you supply one, so exclusionList has to
re-add the defaults:
const { makeMetroConfig, exclusionList } = require('@rnx-kit/metro-config');
const buildOutputDirs = [
// `.cxx` anywhere: the CMake/ninja native build tree. Measured as the single
// biggest churn source -- 62,389 watcher events from `android/app/.cxx/Debug/<hash>`
// in one idle two-minute window.
/[/\\]\.cxx[/\\].*/,
/[/\\]android[/\\]\.gradle[/\\].*/,
/[/\\]android[/\\]build[/\\].*/,
/[/\\]android[/\\]app[/\\]build[/\\].*/,
/[/\\]ios[/\\]build[/\\].*/,
/[/\\]ios[/\\]DerivedData[/\\].*/,
];
module.exports = makeMetroConfig({
resolver: { blockList: exclusionList(buildOutputDirs) },
});
Installing Watchman also avoids it, by not using FallbackWatcher at all.
Worth noting for anyone else debugging this: the pattern must match the real path. Ours was
initially /android/.cxx/ while the directory is android/app/.cxx, which cut the latency
only ~3x and made it look like the diagnosis was wrong.
Suggested fixes
- Index
#dirRegistryso subtree removal is not a full scan — a prefix tree, or a
parent→children map, making#unregisterDirproportional to the subtree. - Skip the work when nothing is registered under the path. The hot path is a file
that vanished, where#unregisterDircan only ever return an empty array. A cheap guard
(if (!this.#dirRegistry[fullPath]) return []) would remove most of the cost without
changing behaviour. - Ship a default
blockListcovering native build output (.cxx,android/build,
android/app/build,android/.gradle,ios/build,ios/DerivedData). Metro watching
its own project's build tree is never useful, and this would fix the common case for
everyone rather than only those who find the workaround. - Consider extending
NativeWatcherbeyonddarwin, or documenting clearly that
Windows and Linux users without Watchman are on a watcher with this cost profile.
(1) or (2) is the real fix; (3) would help every RN project on Windows immediately.
Possibly related
This describes the same blank-DevTools surface symptom without a root cause, and may be the
same bug for the ones on machines without Watchman — worth cross-referencing:
- react/react-native#57436 — "Reporting a bug for React Native DevTools"
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/metro-file-map/src/watchers/FallbackWatcher.js, reading #unregisterDir and its call from #normalizeChange. Reproduce or inspect the vanished-path error flow, then choose a fix that avoids scanning the whole directory registry for file unlink events while preserving subtree removal behavior. Done means the watcher handles transient deletions without repeated full-registry scans.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js, react-native
- Domain
- performance, tooling
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100