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.
Summary
console.log every 100 ms. Every line arrives, in order and unmangled.terminal.log, such as client logs. The status blinks off after each line until the next redraw.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
main's behaviour.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.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 build | Copies, main → PR | Status is the bottom line, main → PR | Longest wait for new progress, main → PR |
|---|---|---|---|
| No outside output | 0 → 0 | 100% → 100% | 220–244 → 209–210 ms |
console.log every 100 ms | 97 → 4 | 31–36% → 2–3% | 176–177 → 2,035–2,039 ms |
console.log every 500 ms | 20 → 20 | 90–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.log | main | PR | 100 ms limit only | 33 ms limit + log() fix | 100 ms limit + log() fix |
|---|---|---|---|---|---|
| 0 | 99.7% | 99.7% | 100% | 100% | 99.8% |
| 3 | 100% | 95.3% | 97.8% | 100% | 99.8% |
| 10 | 100% | 87.5% | 96.2% | 99.7% | 100% |
| 30 | 95% | 52% | 53% | 99.7% | 99.7% |
| 100 | 42% | 16% | 16% | 99.7% | 99.7% |
| 300 | 16% | 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.
| Build | main (ms) | PR (ms) | PR − main (95% CI) | 33 ms limit + log() fix (ms) | Fix − main (95% CI) |
|---|---|---|---|---|---|
| Warm | 1,228 | 1,366 | +138 +128 … +146 | 1,230 | +2 −7 … +16 |
| Warm, 7,317 logged lines | 1,380 | 1,512 | +132 +113 … +146 | 1,408 | +28 +16 … +42 |
| Cold | 10,455 | 10,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 speed | main, cold | PR, cold | 33 ms limit, cold | 100 ms limit, cold | main, warm | PR, warm |
|---|---|---|---|---|---|---|
| Unlimited | 10.45 s | 10.45 s | 10.44 s | 10.3–11.0 s | 1.23 s | 1.37 s |
| 8 KB/s | 10.3 s | 10.2 s | 10.2 s | – | – | – |
| 5 KB/s | 10.3 s | 11.5 s | 11.3 s | – | 1.24 s | 1.37 s |
| 3 KB/s | 10.4 s | 19.4 s | 19.0 s | 10.2 s | 1.24 s | 1.38 s |
| 2 KB/s | 10.4 s | 31.3 s | 30.3 s | 10.3 s | 1.24 s | 1.81 s |
| 1 KB/s | – | – | – | – | 2.84 s | 3.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
- No outside output: The bar updates at about 30 Hz against 10 Hz, with no leftovers and the same final state.
- Wrapping and resizing: Clean at 20 and 40 columns, including multi-line and two-bundle statuses. Narrowing the window from 120 to 40 columns leaves one reflowed line on both sides. The bytes differ (
\ragainst a long cursor-left), and the rendered result is the same. - Windows 11 through
ssh -tt: Erasing, wrapping, logs and thebeforeExitflush render correctly. A burst leaves a copy per line onmainand none between its lines on the PR. This ran theTerminalclass on its own through conhost, without a full Metro build. - HMR: "Done" lines arrive within about 1 ms on both sides, including straight after a
console.log. A rebuild under 333 ms following a log shows no bar, which reads as cleaner. - Key handling: Fine in raw mode with a menu status. The stale menu copy left on
mainwhen a line lands right after a build was gone in the one run of this scenario on each side. - Exit: A natural exit writes the latest status.
process.exit()and an uncaught error leave the same or a newer status. After Ctrl-C the status stayed on screen in 5 of 6 runs against 6 of 6, with the cursor correct in all of them. The sixth run simply has no status left. - Writers
bytesWrittencan't see: Lines fromstdio: 'inherit'children andfs.writeSync(1, …)are erased by the next redraw and a status copy stays, the same on both sides. This is the known limit from react/metro#2130. - Separate streams: With a TTY on
stdoutandstderrpiped,stderrbytes count as outside output though they never reach the terminal. That summing already exists onmain(60 copies at 10 lines/s, against 3 here), and on the PR the bar updates only about every 2 s in this setup. - Piped
stdout: 3 status lines over a 6 s build on both sides. - API:
#isUpdatingand#hasPendingUpdatewere private, so removing them isn't an API change.[Breaking]is the right scope for the removed event type.
Smaller points
- Mixed traffic loses the benefit.
#writerolls outside bytes into the count, soconsole.logfollowed byterminal.loglooks quiet to the next status. Alternating the two at 10 lines/s leaves 60 copies on both sides. - Old output still delays the next status. The wait compares bytes, not time, so one
console.loglong ago delays the next status by 333 ms: 336 ms against 1 ms after 3 s of quiet. Log lines aren't affected. - Several
Terminals at once. 11 or more updating together triggerMaxListenersExceededWarningonbeforeExit. - Non-TTY
waitForUpdates(). With the process kept alive, it takes 3.5 s against 35 ms when the status has changed. Metro,@expo/cliand React Native's community CLI plugin don't call it.
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.
- Progress events:
MetroTerminalReporteremitsbundling:progressfrom the removedbundle_transform_progressed_throttledevent. Moving tobundle_transform_progressedmeans handling every event, so it needs a limit of its own. - Warnings and errors:
LogRespectingTerminalclears the status, flushes, writes tostderrdirectly and restores the status. That write counts as outside output, so each warning hides the status for up to 333 ms. At 10 warnings/s it's on screen 1.7% of a build against 100%. This is one run of the same logic in the harness, not of Expo CLI itself. - Types:
TerminalReporter.types.tsdeclares the removed_scheduleUpdateBundleProgress.
Recommendation
- Limit rendering in
TerminalReporterto one progress update per 100 ms. This removes the warm build cost and the slow-reader regression, and it's a timestamp check, solodash.throttlestays out. The bar stays at the 10 Hz it has today. Without a trailing render, the count on screen can lag until the next event orbundle_build_done. - Restore the status in
Terminal.log(), in the same write as the line. This removes the blink at every rate measured. It writes more at very high log rates, and if that matters more, the status can be restored only below some rate. - Add a test that the status stays on the screen across
log(). The new tests use fake timers over a small screen model with real escape handling, which suits this well, and they don't cover it yet. - Consider the 333 ms quiet period against lines 333–500 ms apart. Lowering it is a judgement call, and leaving it as it is seems reasonable.
// 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
| Revision | Date | Change |
|---|---|---|
| 1 | 2026-11-05 | First published. |
Each revision links to a frozen copy of the report as it was published. The commit history has every edit.