Terminal output and Metro's main thread
How much main-thread time terminal output takes during cold and warm Metro builds, when a terminal can hold a build back, and what react/metro#2162 changes, measured on real dev-server builds of Mattermost through real PTYs and terminal emulators.
Summary
write call.The progress bar isn't a meaningful cost, even in a warm build, where the main thread is the bottleneck and each millisecond of writes adds a millisecond of build time. Its writes are driven by a timer rather than by module count, so they stay at about 0.5 ms per second of build however busy the main thread is.
Two things do cost main-thread time: log lines, which scale with how much is logged, and terminals that stop reading, because TTY writes block the main thread on POSIX. Every terminal measured reads at least 1,500× faster than the progress bar needs, so stalls and very slow links are the realistic risk, not terminal speed. react/metro#2162 makes log lines slightly more expensive and leaves the progress bar unchanged.
The progress bar's cost in a normal build
Terminal writes take 0.04% of main-thread time in a warm build and 0.13% in a cold one. Including everything else the reporter does, such as handling 14,638 progress events and formatting status lines, the totals are 0.25% and 0.37%.
| Build | Build time | Main thread busy | Stream writes | Write time, % of busy | Reporter time, % of busy |
|---|---|---|---|---|---|
| Warm | 1,224 ms | 1,189 ms (97%) | 28 | 0.5 ms (0.04%) | 3.0 ms (0.25%) |
| Cold | 9,998 ms | 5,684 ms (57%) | 286 | 7.2 ms (0.13%) | 21.2 ms (0.37%) |
Dev bundle through a PTY with a fast reader, on the PR. Medians of 16 warm and 24 cold runs. Busy time is event-loop utilisation over the build. The build makes 15 status updates when warm and 100 when cold. Reporter time includes status formatting and the synchronous part of each write.
A warm build doesn't make the progress bar relatively more expensive. The reporter throttles progress to one update per 100 ms, and each redraw is three short writes (move cursor, clear, status line), so the cost follows wall time rather than work done. In both build types that's about 0.4–0.7 ms of writes per second. What the warm build changes is how directly those writes reach build time, because a main thread that is 97% busy has no slack to absorb them.
Removing terminal output doesn't measurably change build time. Real emulators agree: warm builds spend 0.4–0.5 ms in writes in the app terminal, kitty and Terminal.app, and cold builds 5–11 ms.
| Build | PTY | stdout to a file | No reporter |
|---|---|---|---|
| Warm | 1,434 ms | 1,434 ms | 1,436 ms |
| Cold | 9,968 ms | 9,955 ms | 9,949 ms |
Medians of 16 warm and 8 cold runs, in a fixed rotation of the arms. Compare within a table only: batches ran at different times on a shared machine, which is why this table's warm times differ from the one above.
Log lines are what cost main-thread time
Each line logged from the main thread during a warm build adds 13–34 µs. Without a worker in the path, that time comes straight out of the build: 7,317 lines add 94–248 ms, or 8–20%. Most of it isn't the write. With the fast PTY reader on the PR, 7,317 lines spend 30 ms in writes and 129 ms in the reporter, which formats each line and recomputes the status line.
| Terminal | No lines | 732 lines | 7,317 lines | Added per line, main | Added per line, PR |
|---|---|---|---|---|---|
| PTY, fast reader | 1,228 ms | 1,244 ms | 1,322 ms | 13 µs | 21 µs |
| App terminal panel | 1,237 ms | 1,254 ms | 1,383 ms | 20 µs | 34 µs |
| kitty | 1,264 ms | 1,287 ms | 1,431 ms | 23 µs | 27 µs |
| Terminal.app | 1,250 ms | 1,275 ms | 1,460 ms | 29 µs | 28 µs |
| stdout to a file | 1,229 ms | – | 1,352 ms | 17 µs | 16 µs |
Warm dev bundle, with build times on main. Lines are synthetic resolver_warning events sent through the real TerminalReporter, standing in for per-resolution warnings. Transforms are cached in a warm build, so workers log nothing. Medians of 12–16 runs per side, or 8 for the file. The real-terminal runs are only comparable within a row.
Cold builds absorb logging better because workers set the pace and the main thread has slack. Worker output of 36,261 lines (3.8 MB) takes 0.2–2.3 s of main-thread write time, but the PR raises build time by only 0.6–2.1%. Those figures are in the PR section.
When a terminal can block a build
A terminal holds a build back only when it reads slower than Metro writes, or stops reading. Node puts TTY streams in blocking mode on POSIX, so stream.write on the main thread waits until the kernel accepts the bytes. A macOS PTY accepts 1,024 bytes with nobody reading, compared with 64 KiB for a pipe. That's about half a second of colour progress output.
Slow readers
| Read speed | Warm build | Slowdown | Cold build | Slowdown |
|---|---|---|---|---|
| Unlimited | 1,254 ms | – | 10,104 ms | – |
| 5 KB/s | 1,233 ms | None | 9,984 ms | None |
| 3 KB/s | 1,247 ms | None | 10,048 ms | None |
| 2 KB/s | 1,250 ms | None | 10,274 ms | 1.02× |
| 1 KB/s | 2,840 ms | 2.3× | 23,604 ms | 2.3× |
| 500 B/s | 5,932 ms | 4.7× | 52,360 ms | 5.2× |
| 300 B/s | 8,999 ms | 7.2× | – | – |
Progress output only, with colours. Medians of 3 warm and 2 cold runs on the PR. The cold builds at 1 KB/s and 500 B/s wrote 23.3 KB and 25.9 KB, so their build times match bytes ÷ read speed: the build can't finish before its progress output has been read.
Progress output is about 1.6 KB/s in a warm build and 2 KB/s in a cold one, and Metro doesn't reduce it for a slow terminal. It can't detect the slowness, because the blocking happens inside write. Measured terminal stacks have a wide margin over that rate.
| Terminal | Read capacity | Margin over 2 KB/s |
|---|---|---|
| PTY, fast reader | 40.6 MB/s | About 20,000× |
| App terminal panel | 32.5 MB/s | About 16,000× |
| kitty | 26.0 MB/s | About 13,000× |
| Terminal.app | 7.1 MB/s | About 3,500× |
Windows 11 console, over ssh -tt | 3.1 MB/s | About 1,500× |
20,000 unformatted lines of about 100 bytes, written without Terminal. One run per terminal.
Stalls
| Scenario | Build time | Added | Longest single write |
|---|---|---|---|
| Warm build, 5 s pause | |||
| No pause | 1,232 ms | – | 0.05 ms |
| Reader stops | 5,756 ms | +4.5 s | 4,489 ms |
^S then ^Q | 5,770 ms | +4.5 s | 4,505 ms |
| Cold build, 10 s pause | |||
| No pause | 9,943 ms | – | 3 ms |
| Reader stops | 18,931 ms | +9.0 s | 8,944 ms |
^S then ^Q | 18,932 ms | +9.0 s | 8,980 ms |
Medians of 6 warm and 3 cold runs per scenario. The pause starts 0.3 s (warm) or 3 s (cold) after the process starts.
A cold build loses almost as much as a warm one, despite having workers, because almost all of the pause lands on the main thread. Since one write spans the pause, the event loop can't run during it. That last point is inferred from the write being synchronous: the server's responsiveness during a stall wasn't probed separately.
^S depends on flow control being on. @react-native/community-cli-plugin puts stdin in raw mode for its key handler, which should turn flow control off, but that wasn't tested.
Impact of react/metro#2162
react/metro#2162 by @vzaidman makes Terminal.log() write immediately instead of queuing lines for the next status update, so logs stay in order with other output. The status path is effectively unchanged, so builds that log nothing are unaffected. Log lines are no longer joined, which raises writes up to 66× in a warm build and 18× in a cold one.
Build time, main against the PR
| Terminal | Main | PR | Δ (95% CI) | Writes |
|---|---|---|---|---|
| Cold, no log lines | ||||
| PTY, fast reader | 9,996 ms | 9,998 ms | +8 ms −45 … +54 | 284 → 286 |
| Warm, no log lines | ||||
| PTY, fast reader | 1,228 ms | 1,224 ms | −4 ms −12 … +2 | 27 → 28 |
| App terminal panel | 1,237 ms | 1,228 ms | −4 ms −30 … +18 | 29 → 28 |
| kitty | 1,264 ms | 1,267 ms | +2 ms −2 … +14 | 29 → 28 |
| Terminal.app | 1,250 ms | 1,243 ms | −8 ms −12 … +31 | 29 → 28 |
| Warm, 74 lines | ||||
| PTY, fast reader | 1,229 ms | 1,234 ms | +1 ms −12 … +10 | 106 → 156 |
| Warm, 732 lines | ||||
| PTY, fast reader | 1,244 ms | 1,259 ms | +17 ms (+1.3%) +6 … +24 | 107 → 816 |
| App terminal panel | 1,254 ms | 1,278 ms | +26 ms (+2.0%) +8 … +37 | 111 → 813 |
| kitty | 1,287 ms | 1,292 ms | +3 ms −4 … +10 | 109 → 813 |
| Terminal.app | 1,275 ms | 1,282 ms | +2 ms −153 … +8 | 112 → 814 |
| Warm, 7,317 lines | ||||
| PTY, fast reader | 1,322 ms | 1,377 ms | +56 ms (+4.2%) +52 … +61 | 113 → 7,406 |
| App terminal panel | 1,383 ms | 1,476 ms | +93 ms (+6.7%) +81 … +104 | 118 → 7,407 |
| kitty | 1,431 ms | 1,467 ms | +38 ms (+2.7%) +29 … +51 | 121 → 7,402 |
| Terminal.app | 1,460 ms | 1,450 ms | −9 ms −67 … +4 | 116 → 7,407 |
Balanced ABBA order. Main and PR columns are medians per side. Δ is the median per group with a bootstrap 95% CI, so it can differ slightly from the difference of the two medians. Cold: 24 runs per side. Warm: 16 per side on the PTY and 12 in emulators.
Worker logging in a cold build
| Terminal | Writes | Write time, main → PR | Build time, main → PR | Δ |
|---|---|---|---|---|
| PTY, fast reader | 2,083 → 37,863 | 205 → 537 ms | 9,981 → 10,036 ms | +0.6% |
| kitty | 2,070 → 37,860 | 524 → 1,738 ms | 10,194 → 10,257 ms | +0.6% |
| Terminal.app | 2,018 → 37,886 | 1,303 → 2,312 ms | 10,596 → 10,700 ms | +1.0% |
| App terminal panel | 2,118 → 37,941 | 261 → 532 ms | 10,226 → 10,446 ms | +2.1% |
36,261 lines (3.8 MB), five per transformed file, logged from workers. Medians of 3 runs per side on the PTY, always main first, and 2 per side in emulators.
The Terminal class alone
| Terminal | 6 s of progress, writes | 300 lines/s, % of wall time in writes | 5,000-line burst |
|---|---|---|---|
| PTY, fast reader | 8 ms | 0.6 → 1.2% | 11 → 25 ms |
| App terminal panel | 9 ms | 0.8 → 1.2% | 20 → 26 ms |
| kitty | 9 ms | 0.7 → 1.1% | 21 → 29 ms |
| Terminal.app | 9 ms | 0.9 → 1.2% | 44 → 71 ms |
Windows 11 console, over ssh -tt | 23–25 ms | 1.8 → 4.2% | 25 → 181 ms |
Main → PR, synchronous write time. Medians of 3 rounds per side, with no Metro build running. These are the only Windows measurements in this report. Progress writes are about the same on both sides.
Recommendation
- Performance isn't a reason to hold back react/metro#2162. Builds that log nothing are unaffected. The largest cost on a normal terminal is +7% on a warm build logging one line per module, and the larger +19% needs a 10 KB/s terminal.
- Leave the progress bar's throttling alone. With at most 0.13% of main-thread time in writes, there's nothing meaningful to win back.
- Reduce repeated main-thread logging where a warm build has it. Removing 7,317 lines saves 94–248 ms, more than any change to
Terminalcan, and most of that cost is in the reporter rather than the write. - Don't move terminal output off the main thread for now. It's the only complete fix for stalls, but these measurements don't show enough cost in normal use to justify it. This is a judgement call, to revisit if stalls turn out to be common.
Method
Each run starts a real Metro.runServer with TerminalReporter and requests one iOS dev bundle (7,316 modules, 59 MB) from a separate curl process. Build time runs from the request to curl exiting. The app is Mattermost mobile at ad6b834 on Metro 0.87.1, with its own Metro config, using the node crawler and a warm file map. Only metro-core's Terminal.js changes between sides, loaded at runtime from cddcf36 (main) or PR commit 8f027e2. The PR's later commit 47808a7 adds an await to waitForUpdates(), which isn't on the write path, and it wasn't measured.
Cold means an empty transform cache and warm means every transform is a cache hit. Writes are timed by wrapping process.stdout.write, which captures blocking because the writes are synchronous. Main-thread busy time is performance.eventLoopUtilization(), which counts time blocked in a write as busy.
PTY runs use a real PTY with a scripted reader that drains as fast as possible, at a set rate, or not at all. Emulator runs execute inside the app terminal panel of Claude Code's desktop app, kitty and Terminal.app. Comparisons between main and the PR use balanced ABBA order and a bootstrap 95% CI over per-group differences. The machine was shared with other work. Rounds waited up to 90 s for the 1-minute load average to fall below 5, but the balanced cold runs had a median load of 9.8. Differences under about 1% should be treated as noise unless a CI excludes zero.
Colour affects the byte counts. The reader-speed, warm logging and emulator runs have colour on. The balanced cold runs and the cold logging runs on the fast PTY have it off, at 9.4 KB of progress output against 20 KB, with similar write time (6.1 ms with colour, 2 runs).
What this doesn't cover:
- Whether emulators were painting. The test machine may not have had an active display and the app panel wasn't necessarily open, so rendering may have been skipped. Their read loops were real, but capacities on a visible, painting terminal may be lower.
- Real slow links, SSH sessions and tmux. Slow readers and stalls were produced by the scripted reader.
- Full Metro builds on Windows, the Windows Terminal app and QuickEdit selection pauses.
- Real warnings. The main-thread lines are synthetic, and one per module is deliberately heavy. Linux wasn't measured, and this is one app on one machine.
PTY reader with a set read speed
#!/usr/bin/env python3
"""Runs a command on a real PTY and drains the master side at a bounded rate.
--rate 0 reads as fast as possible. --stall-at/--stall-for stop reading for a window.
--xoff-at/--xoff-for send ^S and ^Q through the line discipline instead."""
import argparse, fcntl, os, pty, select, signal, struct, sys, termios, time
p = argparse.ArgumentParser()
p.add_argument('--rate', type=float, default=0)
p.add_argument('--stall-at', type=float, default=-1)
p.add_argument('--stall-for', type=float, default=0)
p.add_argument('--xoff-at', type=float, default=-1)
p.add_argument('--xoff-for', type=float, default=0)
p.add_argument('--cols', type=int, default=120)
p.add_argument('--rows', type=int, default=40)
p.add_argument('--save', default=None)
p.add_argument('--max-seconds', type=float, default=240)
p.add_argument('cmd', nargs=argparse.REMAINDER)
a = p.parse_args()
cmd = a.cmd[1:] if a.cmd and a.cmd[0] == '--' else a.cmd
pid, fd = pty.fork()
if pid == 0:
os.execvp(cmd[0], cmd)
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', a.rows, a.cols, 0, 0))
os.kill(pid, signal.SIGWINCH)
save = open(a.save, 'wb') if a.save else None
t0 = time.monotonic()
total = 0
xoffed = xoned = False
chunk = 65536 if a.rate == 0 else max(1, min(4096, int(a.rate / 100) or 1))
while True:
now = time.monotonic() - t0
if now > a.max_seconds:
os.killpg(pid, signal.SIGKILL)
print('WATCHDOG killed', file=sys.stderr)
break
if a.xoff_at >= 0 and not xoffed and now >= a.xoff_at:
os.write(fd, b'\x13'); xoffed = True
if xoffed and not xoned and now >= a.xoff_at + a.xoff_for:
os.write(fd, b'\x11'); xoned = True
if a.stall_at >= 0 and a.stall_at <= now < a.stall_at + a.stall_for:
time.sleep(0.01);
continue
if a.rate > 0:
allowed = a.rate * now - total
if allowed < 1:
time.sleep(min(0.01, (1 - allowed) / a.rate))
continue
r, _, _ = select.select([fd], [], [], 0.05)
if fd in r:
try:
data = os.read(fd, chunk if a.rate == 0 else max(1, min(chunk, int(allowed))))
except OSError:
break
if not data:
break
total += len(data)
if save: save.write(data)
else:
done, _ = os.waitpid(pid, os.WNOHANG)
if done:
pid = 0
break
if pid:
try: os.waitpid(pid, 0)
except OSError: pass
print(f'pty drained {total} bytes in {time.monotonic()-t0:.2f}s', file=sys.stderr)PTY buffer measurement
# Measures how many bytes a PTY (or pipe) accepts with nobody reading before a write blocks.
import os, pty, fcntl, termios, select, time
def cap(w, n):
fl = fcntl.fcntl(w, fcntl.F_GETFL); fcntl.fcntl(w, fcntl.F_SETFL, fl | os.O_NONBLOCK)
t = 0; b = b'x' * n
while True:
try:
t += os.write(w, b)
except BlockingIOError:
return t
for n in (100, 1024):
m, s = pty.openpty()
print('pty chunk', n, 'capacity', cap(s, n)); os.close(m); os.close(s)
r, w = os.pipe(); print('pipe capacity', cap(w, 1024))Main-thread log lines, inside the reporter wrapper
const inner = new TerminalReporter(new Terminal(process.stdout));
const reporter = {
update(e) {
inner.update(e);
if (mainLogEvery > 0 && e.type === 'bundle_transform_progressed' && ++progressEvents % mainLogEvery === 0) {
inner.update({
type: 'resolver_warning',
message: `The package "some-package" is imported from a path not listed in its exports: file-${progressEvents}.js`,
});
}
},
};Revisions
| Revision | Date | Change |
|---|---|---|
| 1 | 2026-11-03 | First published. |
Each revision links to a frozen copy of the report as it was published. The commit history has every edit.