Repository navigation
FallbackWatcher: #unregisterDir rescans the whole directory registry per unlink, stalling the dev server on Windows #1915
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:
- Reporting a bug for React Native DevTools react-native#57436 — "Reporting a bug for React Native DevTools"
Activity
robhogan commentedon Sep 8, 2026
Hmm, while it's true that
#unregisterDirloops over all watched directories and isn't optimally efficient, I find it quite hard to believe it's causing perceptible slowdown on its own. It performs on the order of hundreds to thousands of string.startsWith and path.join calls that would total maybe 10ms, not seconds. And it's synchronous so it'd completely block responses until it's finished, which doesn't fit "responds but slowly".To get some insight into what Metro is doing, could you start the CLI with
DEBUG=Metro:*and record the output before and while it's responding slowly?Edit: sorry, I've just re-read your "Evidence" section and the CPU profile is persuasive, but still - that's really excessive FS thrash from a directory we shouldn't be watching at all. I'd say that's the fix to make in the first instance.