react/metro #2170 · benchmark

Waiting for bursts of external writes: what an interactive terminal shows

What react/metro#2170 changes for a person watching a Metro build, and what it costs the build, measured on a real PTY, rendered through a terminal model, and benchmarked on Mattermost with the method from the terminal output report.

PR head afd65a3, base 548fc48Metro 0.87.1 with Terminal.js and TerminalReporter.js swappedMattermost mobile ad6b834, 7,316 modulesNode 24.21.0, macOS, M4 Pro, 8 workers@xterm/headless 5.5.0Measured 2026-11-05Published 2026-11-05

Summary

VerdictLand after two small fixesThe burst handling does what it sets out to do. Rendering and logging need a small change each.
Status copies at 10 lines/s97 → 4Cold Mattermost build with console.log every 100 ms. Every line arrives, in order and unmangled.
Status on screen at 30 lines/s95% → 52%Lines through terminal.log, such as client logs. The status blinks off after each line until the next redraw.
Warm build+11%1,228 ms → 1,366 ms (CI +128 to +146 ms). The status is rebuilt for each of 14,638 progress events on a busy main thread.

Thanks to @vzaidman, bursts of outside output now leave far fewer stale status lines, and the erase sequences hold up with wrapping, resizing and on Windows. The status also behaves as well as or better than on main around HMR, key handling and process exit.

Three costs don't appear in the PR's benchmarks, which use piped output and cold builds. All three come from the status redraw path rather than the burst handling, and a 100 ms render limit in the reporter plus restoring the status in log() removes them. Neither change needs a dependency. The patch is under Recommendation.

Tradeoffs

Up to 3× slower
Slow links hold a cold build back at rates that were fine before33 ms redraws raise output from 21 KB to 58 KB, about 2 KB/s to 5.6 KB/s. A reader at 3 KB/s takes a cold build from 10.4 s to 19.4 s, and at 2 KB/s to 31.3 s. Only slow or stalled links are affected, and a 100 ms render limit restores main's behaviour.
+138 ms warm
Warm builds are 11% slower with a terminal attachedBuilding the status per progress event takes reporter time from 3.1 ms to 134.5 ms. A render limit in the reporter takes the difference to +2 ms (CI −7 to +16).
95% → 52%
The status blinks during terminal.log trafficEach line erases the status, which stays away for the rest of the 33 ms delay. It's most visible at 10–100 lines/s, and it applies to Metro's own logs, with no outside writer involved.
Design choice
Regular output freezes the bar with no fewer copiesWith a line every 500 ms, copies stay at 20, and the status is the bottom line 31% of the time against 90–93%. A shorter quiet period trades this against more copies.
Design choice
Sustained output shows much less progressThe bar jumps every 2 s at 10 lines/s. At 300 lines/s on 40 rows the status scrolls away and is on screen for 6% of the build. Short builds can show no bar at all. The log output itself shows activity, so this looks like the right tradeoff.

Bursts and sustained outside output

At 10 lines/s, main leaves a status copy between almost every pair of lines, and the PR leaves one about every 2 s. No line was lost, reordered or mangled in any run, including alternating stdout and stderr bursts.

main, 2.1 s into the build

 BUNDLE  ./index.js ░░░░░░░░░░░░░░░░ 0% (51/3000)
EXT 00002 out
 BUNDLE  ./index.js ░░░░░░░░░░░░░░░░ 0% (101/3000)
EXT 00003 out
 BUNDLE  ./index.js ░░░░░░░░░░░░░░░░ 0% (151/3000)
EXT 00004 out
 BUNDLE  ./index.js ░░░░░░░░░░░░░░░░ 0% (202/3000)
EXT 00005 out

PR, 2.1 s into the build

 BUNDLE  ./index.js ░░░░░░░░░░░░░░░░ 0% (32/3000)
EXT 00001 out
EXT 00002 out
EXT 00003 out
EXT 00004 out
EXT 00005 out
EXT 00006 out
EXT 00007 out

The first eight rows of the rendered screen in a synthetic 6 s build with a line every 100 ms. The status lines continue below in the same pattern.

Cold Mattermost buildCopies, main → PRStatus is the bottom line, main → PRLongest wait for new progress, main → PR
No outside output0 → 0100% → 100%220–244 → 209–210 ms
console.log every 100 ms97 → 431–36% → 2–3%176–177 → 2,035–2,039 ms
console.log every 500 ms20 → 2090–93% → 31%176–179 → 541–543 ms

Real Metro.runServer builds, 2 runs per side, ranges shown. Copies are status lines left in the whole scrollback at the end. In the no-output row, the longest wait reflects main-thread stalls in the build and not the terminal code.

The rest of the picture comes from synthetic 6 s builds. At 300 lines/s on 40 rows, the status is on screen 6% of the time on the PR, always on main, with 3 copies against 60 (one run per side). A warm Mattermost build with a line every 100 ms finishes inside the 2 s cap, so the PR shows no progress at all in either of 2 runs, against 8–10 copies on main.

The status blinks during terminal.log traffic

At 30 lines/s through terminal.log, the status is on screen 52% of the time against 95% on main. log() erases the status and writes the line, but the redraw then waits for the rest of the 33 ms status delay, which is nearly always running during a build. On main, the redraw after a log is usually synchronous. client_log, worker_stdout_chunk and warnings all take this path. If lines land at random within the delay, each costs about 17 ms of status on average, which roughly fits the table (an inference from the code and the measured rates).

Lines/s through terminal.logmainPR100 ms limit only33 ms limit + log() fix100 ms limit + log() fix
099.7%99.7%100%100%99.8%
3100%95.3%97.8%100%99.8%
10100%87.5%96.2%99.7%100%
3095%52%53%99.7%99.7%
10042%16%16%99.7%99.7%
30016%20%14%100%99.8%

Share of a synthetic 6 s build with the status on the 40-row screen, sampled at 60 Hz. Medians of 3 runs for main, the PR and the 33 ms column, and 2 runs for the 100 ms columns.

Real Mattermost builds agree. At about 3 lines/s from the main thread, the status is on screen 99.7% of the time on main and 95–97% on the PR. With one worker line per module, about 730 lines/s, it's 13–14% and 6–9%. So main blinks too at very high rates, and the fix goes further than restoring it: 98.9–99.8% in those two builds, with a 33 ms limit.

That costs output at high log rates, because the status is written again with each line: 2.29 MB against 0.86 MB for the worker case on main. Cold build time doesn't change, and a warm build with 7,317 lines takes +28 ms against main, compared with +132 ms on the PR.

Build time and main-thread cost

A warm build is 11% slower on the PR, and a limit in the reporter removes the difference. Warm builds are nearly all main-thread work, so the cost of building the status for each of 14,638 progress events reaches build time directly. Cold builds have slack, so build time doesn't move, but the same work adds 328 ms of main-thread CPU. Terminal already limits writes here, so this isn't a write cost.

Buildmain (ms)PR (ms)PR − main (95% CI)33 ms limit + log() fix (ms)Fix − main (95% CI)
Warm1,2281,366+138
+128 … +146
1,230+2
−7 … +16
Warm, 7,317 logged lines1,3801,512+132
+113 … +146
1,408+28
+16 … +42
Cold10,45510,448−7
−95 … +114
10,435−20
−98 … +94

Dev bundle through a PTY with a fast reader. Medians of 16 runs per side (warm), 8 (warm with lines) and 6 (cold). Reporter time, main → PR → fix: 3.1 → 134.5 → 4.1 ms warm, 132 → 261 → 162 ms with lines, 22 → 407 → 57 ms cold. Bytes written: 2.0 → 3.4 → 3.3 KB warm, 0.93 → 0.93 → 2.43 MB with lines, 21 → 59 → 56 KB cold. Main-thread CPU on the PR against main: +148 ms warm (CI +110 to +186), +119 ms warm with lines (CI +84 to +154), +328 ms cold (CI +246 to +413). Against main, the fix's CIs for CPU include zero when warm and cold.

The PR's own tables measure metro build with piped output. Its wall-time saving comes from no longer waiting on a trailing throttle timer at exit, in non-TTY builds. It's a real gain, separate from the costs here. A piped run of a small fixture exited about 3.3 s sooner than on the base.

Slow readers

Builds on the PR slow down below about 8 KB/s, where main holds to 2 KB/s, because TTY writes block the main thread on POSIX and redraws follow wall time. The earlier report describes that mechanism. A 33 ms render limit doesn't help here, because it's the redraw rate that matters. A 100 ms limit brings output back to about 19 KB and removes the slowdown.

Read speedmain, coldPR, cold33 ms limit, cold100 ms limit, coldmain, warmPR, warm
Unlimited10.45 s10.45 s10.44 s10.3–11.0 s1.23 s1.37 s
8 KB/s10.3 s10.2 s10.2 s–––
5 KB/s10.3 s11.5 s11.3 s–1.24 s1.37 s
3 KB/s10.4 s19.4 s19.0 s10.2 s1.24 s1.38 s
2 KB/s10.4 s31.3 s30.3 s10.3 s1.24 s1.81 s
1 KB/s––––2.84 s3.82 s

Medians of 2 runs per side (cold) and 3 (warm), with the read rate set by a reader on the PTY, not a real slow link. The limit columns include the log() fix, which doesn't write more without log lines. The unlimited cold row is from the 6-run comparison above, except for the 100 ms column, which shows its 2 runs.

What's unchanged or better

Smaller points

Expo CLI, when it adopts this version

Expo pins its Metro version, so nothing here affects it until then, and none of this needs to hold up the PR. Three things will need attention in @expo/cli at that point.

Recommendation

// TerminalReporter.update()
this._log(event);
this._updateState(event);
if (event.type === 'bundle_transform_progressed') {
  const now = performance.now();
  if (now - (this._lastProgressRender ?? -Infinity) < 100) {
    return;
  }
  this._lastProgressRender = now;
}
this.terminal.status(this._getStatusMessage());

// Terminal.log()
const shownStatus = this.#writtenStatus;
this.#eraseWrittenStatusMaybe();
const isErased =
  this.#ttyStream != null &&
  shownStatus.length > 0 &&
  this.#getBytesWritten() === this.#bytesWrittenAfterLastWrite;
this.#write(
  util.format(format, ...args) + '\n' + (isErased ? shownStatus + '\n' : ''),
);
if (isErased) {
  this.#writtenStatus = shownStatus;
}
this.#scheduleStatusUpdate();

These are the measured changes, applied to Babel-compiled copies of the PR's files. _log and _updateState still run per event, so subclass overrides see every event.

Method

Results are AI-driven. Build measurements follow the terminal output report: a real Metro.runServer serving Mattermost's dev bundle (7,316 modules, 59 MB) to a separate curl process, on a real PTY, with a Python reader at a set rate. Cold means an empty transform cache, warm means all transforms cached. Metro is 0.87.1 with only Terminal.js and TerminalReporter.js replaced at runtime by Babel-compiled versions from the PR's head and base, so the rest of Metro isn't main.

Arms ran in rotating balanced orders. Intervals are bootstrap 95% CIs on the difference in medians, treating runs as independent. The machine is shared: the 1-minute load was about 3–5 during warm runs and 8–14 during cold ones.

To see what a person sees, a recorder drains the PTY as fast as it can and timestamps each read. Each recording is then replayed through @xterm/headless, with a resize applied at its recorded time. The screen is sampled at 60 Hz. "On screen" means a progress line is in the viewport after the latest read, and "bottom line" means it's the last non-empty row. Copies are progress lines in the full scrollback at the end. 120 columns by 40 rows unless stated.

Synthetic scenarios run a 6 s build of 3,000 modules through the real TerminalReporter and Terminal, with progress events every few milliseconds and outside output on a timer. They cover log rates, exit paths, wrapping and HMR-sized rebuilds. Windows runs used a small script around the Terminal class, through ssh -tt to a Windows 11 machine.

Not measured: an emulator painting to a display, so visible flicker is inferred from the terminal state over time. Also real SSH or mobile links, Windows Terminal, a full Metro build on Windows, apps other than Mattermost, and fs.writeSync or stdio: 'inherit' on Windows. The 100 ms limit plus log() fix ran only in the log-rate scenarios and cold builds, so its warm build figure is inferred from the 33 ms version, which renders more often.

Screen sampling
const term = new Terminal({cols, rows, scrollback: 50000, allowProposedApi: true});
const STATUS = /(?:BUNDLE|PREBUNDLE)\s.*\((\d+)\/(\d+)\)/;
for (const {t, data} of recording) {
  await new Promise(resolve => term.write(data, resolve));
  const rows = viewportRows(term);
  frames.push({t, onScreen: rows.some(r => STATUS.test(r)), bottom: STATUS.test(lastNonEmpty(rows))});
}
for (let t = start; t < end; t += 16.67) {
  // Take the latest frame at or before t, and count onScreen and bottom.
}
Scenario shape
const reporter = new TerminalReporter(new Terminal(process.stdout));
reporter.update({type: 'bundle_build_started', buildID, isPrefetch: false, bundleDetails});
// Every 5 ms: catch up to elapsed / duration * total, then the scenario's output.
reporter.update({type: 'bundle_transform_progressed', buildID, transformedFileCount, totalFileCount});
// Outside output:
console.log(line);
// Output through Terminal, at the rate under test:
reporter.update({type: 'client_log', level: 'log', data: [line]});
reporter.update({type: 'bundle_build_done', buildID});

Revisions

RevisionDateChange
12026-11-05First published.

Each revision links to a frozen copy of the report as it was published. The commit history has every edit.