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 start runs 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:
NativeWatcher is macOS-only, so Windows and Linux always get FallbackWatcher:
static isSupported() {
return platform() === "darwin";
}
android/app/.cxx is 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 custom metro.config.js.
npx react-native start
npx react-native run-android — this creates android/app/.cxx with 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
#dirRegistry so subtree removal is not a full scan — a prefix tree, or a
parent→children map, making #unregisterDir proportional to the subtree.
- Skip the work when nothing is registered under the path. The hot path is a file
that vanished, where #unregisterDir can 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
blockList covering 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
NativeWatcher beyond darwin, 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:
Summary
On any platform without Watchman,
metro-file-mapfalls back toFallbackWatcher. Its#unregisterDir()iterates every key in the directory registry on every unlink-ishevent. 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 oftransient 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-configreact-native@react-native/dev-middlewareSymptom
npx react-native startruns and bundles successfully (24.5 MB bundle, no errors).shows a blank window — white, grey, then white — and never populates.
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:#dirRegistryis a flat map keyed by absolute directory path, holding every watcheddirectory in the root (including
node_modules). Finding the subtree underdirpathrequires a linear scan with a
startsWithper key, so the cost is proportional to the sizeof the whole registry — not to the size of the subtree being removed.
It is called from the error path of
#normalizeChange:Any watched path that has already gone by the time it is
stated lands here — which is thenormal 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:android/app/.cxxis inside the watched root and is not excluded by any defaultblockList. It is the single largest churn source in a default RN Android project.Evidence
Node
--cpu-profof the Metro process while the project was idle:#unregisterDir(FallbackWatcher).Instrumenting the watcher's event emission over one idle two-minute window:
android/app/.cxx/Debug/<hash>.After excluding
.cxxfrom theblockList(workaround below), with nothing else changed:Reproduction
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/ninjabuild tree, inside the watched root.
curl -w "%{time_total}"against the devserver 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 thatmakeMetroConfigreplaces its ownblockListwhen you supply one, soexclusionListhas tore-add the defaults:
Installing Watchman also avoids it, by not using
FallbackWatcherat all.Worth noting for anyone else debugging this: the pattern must match the real path. Ours was
initially
/android/.cxx/while the directory isandroid/app/.cxx, which cut the latencyonly ~3x and made it look like the diagnosis was wrong.
Suggested fixes
#dirRegistryso subtree removal is not a full scan — a prefix tree, or aparent→children map, making
#unregisterDirproportional to the subtree.that vanished, where
#unregisterDircan only ever return an empty array. A cheap guard(
if (!this.#dirRegistry[fullPath]) return []) would remove most of the cost withoutchanging behaviour.
blockListcovering native build output (.cxx,android/build,android/app/build,android/.gradle,ios/build,ios/DerivedData). Metro watchingits 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.
NativeWatcherbeyonddarwin, or documenting clearly thatWindows 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: