react/metro · investigation

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.

Metro 0.87.1, Terminal.js from cddcf36 and PR 8f027e2Mattermost mobile ad6b834, 7,316 modulesNode 24.21.0, macOS, M4 Pro, 8 workersMeasured 2026-11-03Published 2026-11-03

Summary

Progress bar, warm build0.04%0.5 ms of writes in 1,189 ms of main-thread work, on a main thread that is 97% busy. 0.13% in a cold build.
Log lines, warm build13–34 µs eachAdded build time per line in real terminals and a fast PTY. 7,317 lines add 94–248 ms to a 1.2 s build.
Stalled terminalBlocks the buildA 5 s pause adds 4.5 s to a warm build, all of it inside one write call.
react/metro#2162Neutral unless loggingNo measurable change without log lines. +0–7% on a warm build logging one line per module.

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%.

BuildBuild timeMain thread busyStream writesWrite time, % of busyReporter time, % of busy
Warm1,224 ms1,189 ms (97%)280.5 ms (0.04%)3.0 ms (0.25%)
Cold9,998 ms5,684 ms (57%)2867.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.

BuildPTYstdout to a fileNo reporter
Warm1,434 ms1,434 ms1,436 ms
Cold9,968 ms9,955 ms9,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.

TerminalNo lines732 lines7,317 linesAdded per line, mainAdded per line, PR
PTY, fast reader1,228 ms1,244 ms1,322 ms13 µs21 µs
App terminal panel1,237 ms1,254 ms1,383 ms20 µs34 µs
kitty1,264 ms1,287 ms1,431 ms23 µs27 µs
Terminal.app1,250 ms1,275 ms1,460 ms29 µs28 µs
stdout to a file1,229 ms–1,352 ms17 µs16 µ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 speedWarm buildSlowdownCold buildSlowdown
Unlimited1,254 ms–10,104 ms–
5 KB/s1,233 msNone9,984 msNone
3 KB/s1,247 msNone10,048 msNone
2 KB/s1,250 msNone10,274 ms1.02×
1 KB/s2,840 ms2.3×23,604 ms2.3×
500 B/s5,932 ms4.7×52,360 ms5.2×
300 B/s8,999 ms7.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.

TerminalRead capacityMargin over 2 KB/s
PTY, fast reader40.6 MB/sAbout 20,000×
App terminal panel32.5 MB/sAbout 16,000×
kitty26.0 MB/sAbout 13,000×
Terminal.app7.1 MB/sAbout 3,500×
Windows 11 console, over ssh -tt3.1 MB/sAbout 1,500×

20,000 unformatted lines of about 100 bytes, written without Terminal. One run per terminal.

Stalls

ScenarioBuild timeAddedLongest single write
Warm build, 5 s pause
No pause1,232 ms–0.05 ms
Reader stops5,756 ms+4.5 s4,489 ms
^S then ^Q5,770 ms+4.5 s4,505 ms
Cold build, 10 s pause
No pause9,943 ms–3 ms
Reader stops18,931 ms+9.0 s8,944 ms
^S then ^Q18,932 ms+9.0 s8,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.

+0–7%
Warm builds that log one line per module7,317 lines on a 1.3–1.5 s build: −9 to +93 ms depending on the terminal. This is a heavy case, and it takes about 7 µs of extra main-thread work per line on the fast PTY.
+19%, edge case
Slow reader with chatty logsAt 10 KB/s with one worker line per file, a cold build takes 95.7 s against 80.5 s, and writes 947 KB against 796 KB. Inferred mechanism: on main, a blocking batched write holds up the status loop, while on the PR it keeps redrawing about every 33 ms and uses up scarce bandwidth. At 100 KB/s with five lines per file the gap is 2% (one run per side).
7× on Windows
Long bursts of log lines in one tick5,000 lines block for 181 ms against 25 ms on a Windows console, and 25–71 ms against 11–44 ms on macOS.
+0.6–2.1%
Cold builds with chatty workers36,261 lines add 0.3–1.2 s of main-thread writes, but workers limit the build, so most of that doesn't reach build time. 2–3 runs per side, so the low end is within noise.
Small gain
Shorter main-thread pauses while loggingIn the warm 7,317-line runs, the longest single write falls from 0.9–2.8 ms to about 0.13 ms on the PTY, app terminal and kitty, and from 13.4 to 10.7 ms on Terminal.app.
Neutral
Builds that log nothingDifferences between main and the PR are within noise in every terminal, for warm builds and cold.

Build time, main against the PR

TerminalMainPRΔ (95% CI)Writes
Cold, no log lines
PTY, fast reader9,996 ms9,998 ms+8 ms
−45 … +54
284 → 286
Warm, no log lines
PTY, fast reader1,228 ms1,224 ms−4 ms
−12 … +2
27 → 28
App terminal panel1,237 ms1,228 ms−4 ms
−30 … +18
29 → 28
kitty1,264 ms1,267 ms+2 ms
−2 … +14
29 → 28
Terminal.app1,250 ms1,243 ms−8 ms
−12 … +31
29 → 28
Warm, 74 lines
PTY, fast reader1,229 ms1,234 ms+1 ms
−12 … +10
106 → 156
Warm, 732 lines
PTY, fast reader1,244 ms1,259 ms+17 ms (+1.3%)
+6 … +24
107 → 816
App terminal panel1,254 ms1,278 ms+26 ms (+2.0%)
+8 … +37
111 → 813
kitty1,287 ms1,292 ms+3 ms
−4 … +10
109 → 813
Terminal.app1,275 ms1,282 ms+2 ms
−153 … +8
112 → 814
Warm, 7,317 lines
PTY, fast reader1,322 ms1,377 ms+56 ms (+4.2%)
+52 … +61
113 → 7,406
App terminal panel1,383 ms1,476 ms+93 ms (+6.7%)
+81 … +104
118 → 7,407
kitty1,431 ms1,467 ms+38 ms (+2.7%)
+29 … +51
121 → 7,402
Terminal.app1,460 ms1,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

TerminalWritesWrite time, main → PRBuild time, main → PRΔ
PTY, fast reader2,083 → 37,863205 → 537 ms9,981 → 10,036 ms+0.6%
kitty2,070 → 37,860524 → 1,738 ms10,194 → 10,257 ms+0.6%
Terminal.app2,018 → 37,8861,303 → 2,312 ms10,596 → 10,700 ms+1.0%
App terminal panel2,118 → 37,941261 → 532 ms10,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

Terminal6 s of progress, writes300 lines/s, % of wall time in writes5,000-line burst
PTY, fast reader8 ms0.6 → 1.2%11 → 25 ms
App terminal panel9 ms0.8 → 1.2%20 → 26 ms
kitty9 ms0.7 → 1.1%21 → 29 ms
Terminal.app9 ms0.9 → 1.2%44 → 71 ms
Windows 11 console, over ssh -tt23–25 ms1.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

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:

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

RevisionDateChange
12026-11-03First published.

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