Dropping graceful-fs from Metro
What metro and metro-file-map lose when graceful-fs is replaced with plain node:fs, checked against graceful-fs' source and measured under a 256-fd hard limit.
Summary
EMFILE (too many open files).fs, hard limit of 256 fds, ~75k readdir calls pending at the peak. Same result as graceful-fs.metro-file-map fork dropped graceful-fs in SDK 56 and uses the Node crawler by default. No EMFILE crawl reports since.graceful-fs was added to Metro's predecessor in 2015 because the packager "uses as much concurrency as possible" and kept running out of file descriptors under a low ulimit. Metro no longer works that way. The crawler's readdir and lstat don't keep any file open between calls, so the crawler never has more than UV_THREADPOOL_SIZE (default 4) fds open at once, however large the tree. Every other graceful-fs feature (the Windows rename retry, the chmod/chown fixes, the polyfills) patches calls Metro never makes.
Two edge cases remain, both in processes that are already almost out of file descriptors. In that state a crawl now skips directories instead of retrying, and a source request returns 500 instead of waiting. Neither is plausible at normal limits. The server case is also failing either way, because each connection uses an fd too.
What graceful-fs did, call site by call site
graceful-fs exports a patched copy of fs. On first load it also wraps the global fs.close and closeSync, so that any close in the process retries queued operations. Metro imported it in exactly three places.
| Call site | fs calls | What graceful-fs added | Replacement |
|---|---|---|---|
| metro-file-map crawlers/node | readdir (async), lstat (async) | EMFILE/ENFILE queue and retry for readdir, up to 60 s. lstat is never queued. | None needed. A retry is an optional follow-up, see below. |
| metro-file-map worker.js | readFileSync | Nothing. graceful-fs doesn't patch sync calls (only readSync EAGAIN, which readFileSync doesn't route through the patched copy). | None |
| metro Server.js | createReadStream (source requests), readFileSync (symbolication) | EMFILE queue on the stream's open. Nothing for readFileSync. | None. Sockets run out first, see the stress test. |
| graceful-fs feature | Applies to | Used by Metro? |
|---|---|---|
| EMFILE/ENFILE queue and retry | open, readdir, readFile, writeFile, appendFile, copyFile, streams | Yes: crawler readdir, Server createReadStream |
| win32 rename retry | rename on EACCES/EPERM/EBUSY, only while the target doesn't exist | No. Metro has no rename in its source. |
| chown/chmod error filters | Ignores EPERM/EINVAL for non-root users | No |
| lchmod / lutimes polyfills | Platforms that lack them | No |
| stat uid/gid fix | Negative uid/gid made unsigned | No. The crawler reads mtime and size only. |
| read EAGAIN retry | read, readSync | No |
gracefulify | Patches the global fs | Never called by Metro |
The rename retry gives up as soon as stat(target) succeeds. So it wouldn't help with concurrent writers racing to rename onto the same cache key either (react/metro#2040's Windows EPERM). That needs its own handling.
Why the crawler can't run out of file descriptors
The Node crawler recurses with no concurrency limit of its own: every directory it sees issues a readdir straight away, and every matching file an lstat. A crawl of 2.3M files had about 75,000 readdir calls pending at its peak. None of them holds an fd while it waits. On macOS and Linux, libuv runs each readdir as one scandir(3) on a threadpool thread, which opens, reads and closes the directory in one job. On Windows it uses CreateFileW and NtQueryDirectoryFile on a handle and never allocates a C runtime fd. lstat needs no fd at all. Open fds are therefore bounded by the threadpool size (4 unless UV_THREADPOOL_SIZE is set), not by the size of the tree.
The 2015 failure mode also needs a low hard limit. Node raises its soft RLIMIT_NOFILE to the hard limit at startup, so ulimit -n 256 on its own has no effect: the test process still opened 92,149 fds (macOS's kern.maxfilesperproc). Everything below was run with ulimit -Sn 256; ulimit -Hn 256.
Stress tests
These are standalone copies of the two code paths, not the PR's code, each run with plain node:fs and with graceful-fs 4.2.11 swapped in. The crawler copy is main's find function from crawlers/node. The server copy is _processSourceRequest's createReadStream(file).pipe(res), with a 500 on error.
Crawler
| Scenario | Files found, fs | EMFILE, fs | Files found, graceful-fs | EMFILE, graceful-fs |
|---|---|---|---|---|
| 2,302,951-file tree, all fds free | 2,302,951 | 0 | 2,302,951 | 0 |
| 30,683-file tree, 2 fds free | 28 | 14 | 30,683 | 0 |
| 30,683-file tree, 1 fd free | 28 | 14 | 30,683 | 0 |
30,683-file tree, 64 free, UV_THREADPOOL_SIZE=128 | 26,668 | 56 | 30,683 | 0 |
Wall time for the full crawl: 56.8 s with fs, 59.0 s with graceful-fs. In the 1–2-free cases graceful-fs completes in about 2.4 s against 0.2 s for a normal crawl, because it waits for its retry timer. Nothing in the crawler closes an fd that would wake the queue sooner. The "fds free" cases were set up by opening files until EMFILE, then closing N of them.
Source requests
| Concurrent connections | fs: 200 | fs: 500 | fs: reset | graceful-fs: 200 | graceful-fs: 500 | graceful-fs: reset |
|---|---|---|---|---|---|---|
| 50 | 3,000 | 0 | 0 | 3,000 | 0 | 0 |
| 110 | 3,000 | 0 | 0 | 3,000 | 0 | 0 |
| 200 | 75 | 2,177 | 748 | 734 | 0 | 2,266 |
3,000 requests per run. Each response is held for 30 ms so that requests overlap, and the client runs in a separate process. Every connection uses an fd for its socket as well as one for the file. So at the point where open fails, accept is failing too, and graceful-fs mostly turns 500s into connection resets. Browsers open at most 6 HTTP/1.1 connections per host, so source requests from DevTools come nowhere near this.
Where removal is a tradeoff
readdir error as "warn and skip", so with plain fs an EMFILE drops that whole subtree from the file map for the session. The next start crawls again and heals it. This needs fewer free fds than threadpool threads, which none of the realistic runs came close to. Optional follow-up: retry readdir on EMFILE/ENFILE with a short backoff (about 10 lines). A concurrency limit won't help, because fd use is already bounded.fs. The retry queue is shared through a global symbol, so other copies of graceful-fs in the process keep working. React Native Community CLI 20.2 calls gracefulify(fs), but only in its rnc-cli binary. react-native start loads the CLI through react-native/cli.js and never runs it. Where it does run, it patches fs before Metro loads, and Metro's default import (import fs from 'node:fs') sees the patched functions.jest-worker → jest-util (and the Community CLI), so install size is unchanged and so is yarn.lock. A Metro process no longer loads it, though: jest-util requires it lazily and the worker children only use isPromise. That saves 2.5–4.5 ms of startup and the global fs.close wrap.Evidence from downstream and history
- Expo has shipped this already.
@expo/metro-file-mapremoved graceful-fs in expo/expo#45378 (SDK 56, May 2026) and made the Node crawler, with plainfsand the same unboundedreaddir/lstatstructure, the default instead of Watchman. - No EMFILE crawl or source-request reports in react/metro or expo/expo. The EMFILE issues that do exist (react/metro#1405, expo/expo#29083, and the Windows note in expo/expo#47662) are
fs.watchrunning out of handles, which graceful-fs never covered. - Higher-volume
fsusers never had it.metro-cache'sFileStorereads and writes every transform result with plainfs. - The original reason no longer applies. The 2015 commit's test plan was
ulimit -n 256against a packager that opened files with unbounded concurrency. Node now raises that soft limit by itself, and the crawler no longer holds files open.
The merge
The PR was six months behind main and conflicted with three crawler and test changes (#1677, #1693, #1937). Merge commit d5ca710 brings it up to main 5abdbc0 and makes three changes beyond resolving those conflicts:
node:fseverywhere, as in the rest of Metro and as main's lint ruleimport/enforce-node-protocol-usagerequires. The crawler andServer.jsuse the default import.- The test mocks in
worker-test,FileProcessor-testandnode-testmove tonode:fs. Theworker-testandFileProcessor-testmocks offsonly reachedworker.jsbecause graceful-fs requiresfsinternally. resolver-test'sgraceful-fsmock is removed. That test already mocksnode:fs, so the plain'fs'import of the original PR breaks it (192 failures).
Verified at d5ca710: full Jest suite (158 suites, 2,982 passed, 1 skipped), Flow, ESLint and Prettier on the changed files, verify-api-snapshots, and the build. yarn.lock is unchanged and --frozen-lockfile passes.
Recommendation
- Land #1676 at d5ca710 once CI is green.
- Optional follow-up: retry the crawler's
readdironEMFILE/ENFILEwith a capped backoff, rather than skipping the directory. It's cheap insurance against a silently incomplete file map. - Don't bring graceful-fs back for
FileStore's atomic writes (#2040). Itsrenameretry gives up once the target exists, which is exactly the case there.
Method
The graceful-fs behavior table comes from reading 4.2.11's graceful-fs.js and polyfills.js, the version yarn.lock resolves for ^4.2.4. The libuv claims come from src/unix/fs.c and src/win/fs.c, and the Node soft-limit behavior from PlatformInit in src/node.cc, confirmed by opening files until EMFILE. The 2.3M-file tree is a directory of about 75 repository checkouts, mostly JavaScript, many with node_modules installed. The 30,683-file tree is one Metro checkout. Windows wasn't stress-tested: there readdir uses no C runtime fd, and the PR's CI was green on Windows before the merge.
Crawler stress script
// Standalone copy of metro-file-map's Node crawler `find` on main (5abdbc0):
// unbounded recursive readdir({withFileTypes}) + lstat per matching file.
// Usage: node crawl.js <fs|graceful> <root> [reserveFds]
'use strict';
const path = require('node:path');
const [, , which, root, reserve = '0'] = process.argv;
const fs = which === 'graceful' ? require('graceful-fs') : require('node:fs');
const exts = new Set(['js', 'jsx', 'ts', 'tsx', 'json', 'mjs', 'cjs', 'png', 'md', 'flow', 'map']);
// Optionally consume fds up to (limit - reserve) to simulate a process already near its limit.
const held = [];
if (+reserve > 0) {
for (;;) {
try { held.push(fs.openSync(__filename, 'r')); } catch (e) { if (e.code !== 'EMFILE') throw e; break; }
}
for (let i = 0; i < +reserve && held.length; i++) fs.closeSync(held.pop());
}
const errors = {};
let files = 0;
new Promise(resolve => {
let activeCalls = 0;
const done = () => { if (activeCalls === 0) resolve(); };
function search(dir) {
activeCalls++;
fs.readdir(dir, {withFileTypes: true}, (err, entries) => {
activeCalls--;
if (err) errors['readdir:' + err.code] = (errors['readdir:' + err.code] || 0) + 1;
else for (const e of entries) {
const file = dir + path.sep + e.name;
if (e.isDirectory()) { search(file); continue; }
if (!exts.has(path.extname(e.name).slice(1)) && !e.isSymbolicLink()) continue;
activeCalls++;
fs.lstat(file, (err, st) => {
activeCalls--;
if (err) errors['lstat:' + err.code] = (errors['lstat:' + err.code] || 0) + 1;
else files++;
done();
});
}
done();
});
}
search(root);
}).then(() => console.log(JSON.stringify({which, files, errors})));