react/metro #1676 · safety review

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.

PR head d5ca710 (main 5abdbc0 merged in)graceful-fs 4.2.11Node 26.11.1, macOSReviewed 2026-10-18

Summary

VerdictSafe to landOnly one graceful-fs feature ever reached Metro's calls: the retry queue for EMFILE (too many open files).
Crawl 2.3M files0 errorsPlain fs, hard limit of 256 fds, ~75k readdir calls pending at the peak. Same result as graceful-fs.
Crawl with ≤2 fds free28 of 30,683The only way to make the crawler fail is a process already out of fds. Directories are then skipped with a warning.
In production5 monthsExpo's 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 sitefs callsWhat graceful-fs addedReplacement
metro-file-map crawlers/nodereaddir (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.jsreadFileSyncNothing. graceful-fs doesn't patch sync calls (only readSync EAGAIN, which readFileSync doesn't route through the patched copy).None
metro Server.jscreateReadStream (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 featureApplies toUsed by Metro?
EMFILE/ENFILE queue and retryopen, readdir, readFile, writeFile, appendFile, copyFile, streamsYes: crawler readdir, Server createReadStream
win32 rename retryrename on EACCES/EPERM/EBUSY, only while the target doesn't existNo. Metro has no rename in its source.
chown/chmod error filtersIgnores EPERM/EINVAL for non-root usersNo
lchmod / lutimes polyfillsPlatforms that lack themNo
stat uid/gid fixNegative uid/gid made unsignedNo. The crawler reads mtime and size only.
read EAGAIN retryread, readSyncNo
gracefulifyPatches the global fsNever 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

ScenarioFiles found, fsEMFILE, fsFiles found, graceful-fsEMFILE, graceful-fs
2,302,951-file tree, all fds free2,302,95102,302,9510
30,683-file tree, 2 fds free281430,6830
30,683-file tree, 1 fd free281430,6830
30,683-file tree, 64 free, UV_THREADPOOL_SIZE=12826,6685630,6830

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 connectionsfs: 200fs: 500fs: resetgraceful-fs: 200graceful-fs: 500graceful-fs: reset
503,000003,00000
1103,000003,00000
200752,17774873402,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

Edge case
A crawl in a process that is out of file descriptors skips directoriesThe crawler treats every 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.
Negligible
Source requests at the fd limit return 500 instead of waitingThis only happens in the band where sockets still fit but files don't, which needs well over 100 concurrent connections at a 256 hard limit. Not something any client of Metro's dev server does.
Neutral
Hosts that patch fs globallyNothing in Metro depends on graceful-fs having patched the global 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.
Small gain
Footprintgraceful-fs stays installed through 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-map removed graceful-fs in expo/expo#45378 (SDK 56, May 2026) and made the Node crawler, with plain fs and the same unbounded readdir/lstat structure, 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.watch running out of handles, which graceful-fs never covered.
  • Higher-volume fs users never had it. metro-cache's FileStore reads and writes every transform result with plain fs.
  • The original reason no longer applies. The 2015 commit's test plan was ulimit -n 256 against 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:fs everywhere, as in the rest of Metro and as main's lint rule import/enforce-node-protocol-usage requires. The crawler and Server.js use the default import.
  • The test mocks in worker-test, FileProcessor-test and node-test move to node:fs. The worker-test and FileProcessor-test mocks of fs only reached worker.js because graceful-fs requires fs internally.
  • resolver-test's graceful-fs mock is removed. That test already mocks node: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 readdir on EMFILE/ENFILE with 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). Its rename retry 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})));