Web UI rendering — measured assessment and design
Date: 2026-10-04
Scope: goa server (the browser-is-the-terminal web UI) — CPU cost, scrolling,
history and where the drawing work should live.
Status: assessment complete; the fixes it prescribes are tracked in bugs.md.
This document is an engineering record, not user documentation (lowercase name =
repository metadata, not a goa:// doc entry).
1. Method
Everything below is measured, not reasoned about:
- Server CPU —
./goa server --perf-load --cpuprofile=…(the engine's own synthetic 60 Hz streaming load), profile read withgo tool pprof -top. - Server per-frame cost vs. history length — Go benchmarks in
internal/webui/scrollback_cost_test.go(BenchmarkTakeScrollback,BenchmarkPublishCost), which drive a realCellGridthrough the real emulator. - Client cost — a real Chrome driven by
agent-browser, measuring the exact code pathapp.jsruns (append transcript rows → follow-tail scroll → forced layout) at increasing transcript sizes. - Client/server totals — CPU-time deltas sampled with
ps -o time=.
The synthetic --perf-load never scrolls a line off the screen, which is why the
first profile looked innocent; the benchmarks below were written specifically to
cover what the synthetic load misses. That gap is itself a finding (§3.1).
2. Measurements
2.1 Where the server's CPU goes under streaming load
goa server --perf-load --perf-load-duration 40s --cpuprofile, one browser
attached, 2.81 s of samples over 40.4 s (6.95 % of one core):
| Share | Cost centre |
|---|---|
| 40.2 % | tui.(*Compositor).Render → Scene.compose, placeLayer, ansi.Width, ansi.Strip (regexp) |
| ~27 % | allocator/GC (mallocgc, spanInlineMarkBits.init, memclrNoHeapPointers) |
| 6.1 % | webui.(*VirtualTerminal).WriteString (emulator re-parse of the compositor's bytes) |
| 4.6 % | webui.(*CellGrid).Patches (re-diff, rowCellsLocked copies) |
| 3.2 % | webui.(*wsClient).write |
| rest | runtime, syscalls, kevent |
The dominant cost is the TUI engine's own compositor, which the terminal path pays too. The web layer proper is ~6 %.
2.2 Per-frame cost vs. transcript length — the real defect
BenchmarkTakeScrollback / BenchmarkPublishCost (120×40 grid, Apple M4 Pro):
| Scrollback history | TakeScrollback |
bytes allocated | whole web frame (parse+diff+runs+transcript) |
|---|---|---|---|
| 0 rows | 15 ns | 0 B | 6.2 µs |
| 100 rows | 86 µs | 0.6 MB | — |
| 1 000 rows | 676 µs | 9.1 MB | 697 µs |
| 5 000 rows | 3.18 ms | 47 MB | — |
| 20 000 rows | 12.5 ms | 190 MB | 12.7 ms |
TermEmulator.ScrollbackCells() deep-copies the entire transcript
(out[i] = append([]CellAttrs(nil), row...) for every row) and
CellGrid.TakeScrollback() calls it once per frame, only to slice off the
handful of rows that are actually new. The cost is O(history) per frame:
- at 20 000 rows and 30 fps that is ~380 ms of CPU and ~5.7 GB of allocation per second — the server pins a core and feeds the GC for nothing;
- it is invisible on a fresh session (6 µs/frame) and gets worse for the entire life of the session. This is the "goa server uses significant CPU" report.
The emulator's transcript is also unbounded (e.scrollback = append(…), no
cap), so server memory grows with session length: ~9.4 KB per retained row at
120 columns → ~190 MB at 20 000 rows, per session.
2.3 Client cost vs. transcript length
Real Chrome, app.js's own path (append rows → scrollToBottom() → forced
layout), measured on the live page:
| Transcript rows in DOM | ms per append+follow-tail frame |
|---|---|
| 2 000 | 1.23 |
| 4 020 | 1.34 |
| 6 040 | 1.51 |
| 8 060 | 1.49 |
| 10 080 | 1.80 |
#scrollback only ever grows: every row that leaves the screen is appended and
kept as DOM forever. Layout cost is linear in session length (~0.17 µs/row), and
scrollToBottom() in onFrame/onScrollback reads scrollHeight, forcing that
layout on every frame that appends a row.
Tested alternatives at 10 000 rows:
| Variant | ms/frame |
|---|---|
| unbounded DOM (today) | 3.81 |
content-visibility: auto on rows (native off-screen skipping) |
1.40 |
| bounded DOM window + spacer | 0.33 (constant) |
content-visibility: auto is native and helps 2.7×, but the cost stays linear
(the container still positions every box). A bounded DOM window with a spacer
element holding the evicted rows' height is O(1) and keeps scrollHeight
truthful, so the native scrollbar, wheel, keyboard and touch scrolling are all
unchanged — the browser still owns scrolling.
Isolated cost of the append path at 10 000 rows (cv:auto active): append only ≈ 0 ms, append + forced layout 1.36 ms, append + scroll + layout 1.61 ms. The forced layout is the cost, not the DOM insertion.
2.4 Client/server CPU totals (synthetic 30 fps stream, 1 browser)
| CPU | |
|---|---|
goa process |
~7 % of one core |
| Chrome renderer | ~4.8 % of one core |
| Chrome GPU | ~5 % of one core |
Neither is alarming for this load — because the synthetic load never scrolls. The two O(history) defects above are what make a real long session expensive.
2.5 Frame encoding is per client
Hub.Publish hands the same *Frame to every client; each transport's writer
goroutine calls codec.EncodeFrame(f) itself (transport_ws.go:318,
transport_sse.go:217). With N attached browsers the same screen is
JSON-encoded, and every wireRun/wireRow re-allocated, N times per frame.
The hub is the only place that knows the fan-out width, so it is the only place
that can encode once.
2.6 Frames are produced with no browser attached
VirtualTerminal.publish always calls grid.Patches() and
grid.TakeScrollback(); Hub.Publish then discards the frame when the client
set is empty. A goa server with nobody watching still pays the whole web cost
(§2.2 included) for every engine frame.
2.7 Real-browser validation (after the fixes)
agent-browser against goa server, driving real /help output through the
real engine, transcript grown past the bound:
| Check | Result |
|---|---|
| Transcript rows in DOM after ~3 500 rows streamed through | 2000 (exactly the bound), stable |
| Total DOM nodes at that point | ~12 000 (bounded; was unbounded before) |
| Follow-tail after trimming | scrollTop == maxScroll — still armed (see §5) |
| Scroll geometry | scrollHeight − clientHeight == transcript height + grid height, exactly |
| Renderer CPU while ~1 000 rows streamed | 0.21 s over 5.1 s ≈ 4 % of one core |
| Renderer CPU idle with a full transcript | 0.02 s over 5 s ≈ 0.4 % of one core |
| Grid height | grid.offsetHeight == 16 + rows*16 (608 for 37 rows) — the phantom-blank-line fix holds |
| Caret | visible, positioned at the input line |
Re-run on the fixed server (2026-10-04, second pass, goa server built from
this revision): the model-independent mechanics section of
e2e/w1_webui_browser.sh executed against real Chrome — 10/10 PASS, with the
new transcript_order assertion (transcript rows keep ascending absolute
indices across grow+shrink cycles):
| Check | Result |
|---|---|
| Page live | #status[data-state=live] |
| One scroll container | transcript in flow above the grid, caret in the grid |
| Caret | shown at left:8px top:536px |
| Scroll + follow-tail | transcript scrolls, pinned to the bottom (rows:345) |
| Scroll-up detaches follow-tail | PASS |
| Transcript bound | 1 656 rows held after ~30 more screens |
| Resize trims | 53 → 27 rows across a shrink |
| Grid height | 864 == 16 + 53×16 |
| Clipboard chords | Ctrl+V/X browser-native, Ctrl+C interrupt unless selected |
| Transcript order across resizes | 2 099 rows, ascending, no stale index |
Two client bugs were found only by this pass (both now fixed and covered by regressions — see §5).
2.8 The dirty-mark backlog — the second O(history)-shaped defect
Found while checking the benchmarks after the fixes: BenchmarkPublishCost was
not flat (130 µs / 0.5 MB at 1 000 rows, 355 µs / 1.7 MB at 20 000). The
cause was not the transcript but CellGrid.pending: the rows marked dirty since
the last frame were kept as a list of every mark taken, appended once per
Process call, then sorted and deduplicated by normalizeRows at the next
Patches. Frames only consume it when a browser is attached, so a burst of
output arriving without a frame paid for the whole burst on the first frame
after it — for a screen that can only ever hold maxRows (200) distinct rows:
| Rows printed since the last frame | first frame after the burst | bytes allocated |
|---|---|---|
| 2 000 | 157 µs | 0.58 MB |
| 20 000 | 281 µs | 1.70 MB |
| 200 000 | 2.46 ms | 18.8 MB |
Process alone is flat in history (41875 B / 362 allocs at every size), which is
what pinned the growth on the pending set rather than on the emulator.
The fix is structural: pending is a set with one flag per screen row, so a
frame costs one pass over the screen however much arrived since the last one, and
its memory is bounded by the geometry (200 bytes). Resize re-sizes the set to
the new geometry and marks every row (a geometry change is a repaint), so no row
of an old screen can be shipped against a new one. normalizeRows (map + sort)
became dead code and was deleted.
3. Findings, ranked by measured impact
TakeScrollbackis O(history) per frame (§2.2). 12.5 ms + 190 MB per frame at 20 000 rows. Critical.- The transcript is unbounded on both sides — server (emulator slice) and client (DOM) (§2.2, §2.3). Cost and memory grow with session length, forever. High.
- Client transcript layout is linear and forced on every appending frame (§2.3). High.
- Per-client frame encoding (§2.5) — server cost scales with the number of viewers instead of being constant. Medium.
- Frames are built with no client attached (§2.6). Medium.
- The compositor is the server's real cost centre (§2.1) but it is the engine, shared with the terminal path, and it is what produces the screen. Moving it is out of scope for the web UI; the web UI's job is to not add to it. Noted, not actioned.
- The dirty-mark backlog — a frame after a burst of output cost the burst (§2.8). 2.46 ms + 18.8 MB after 200 000 unframed rows, for at most 200 distinct rows. High — fixed in the same pass (a set per screen row).
4. Design
4.1 Principles
- The server ships what moved; the browser draws. The wire format already is a row delta; the browser already owns the cell model, the runs → DOM mapping, the caret and the scrolling. Keep it that way — the fixes below make the server stop doing work that is proportional to the past and let the browser stop doing work proportional to the past.
- Native web for scrolling and history. A real overflow container, a real
scrollbar, browser-owned wheel/keyboard/touch/overscroll. No JS-emulated
scrolling, no
preventDefaulton wheel.overscroll-behavior: autostays, so a gesture past either end chains to the browser (history swipe / page). Windowing must therefore be invisible to the scroll geometry: the spacer preservesscrollHeight, so the scrollbar never lies. - Bounded work per frame. Every per-frame path must be O(changed), never O(session). This is the property that makes a 10-hour session cost the same as a 10-second one.
4.2 Server
| Change | Why |
|---|---|
TermEmulator: expose incremental transcript access (ScrollbackLen, ScrollbackRow) instead of a whole-buffer deep copy |
kills finding 1 |
TermEmulator: cap the transcript (ring, with an absolute base index) |
bounds memory (finding 2) |
CellGrid.TakeScrollback: read only the rows since sentScrollback, clamped to the ring base |
kills finding 1 |
Encode each frame once in the Hub; clients write the shared bytes |
kills finding 4 |
| Skip frame production when no client is attached | kills finding 5 |
CellGrid.pending: a dirty set (one flag per screen row) instead of a list of every mark; re-sized and fully marked on Resize |
kills finding 7 |
4.3 Client
| Change | Why |
|---|---|
| Transcript bounded at 2000 rows; the oldest fall off in batches, with the scroll offset compensated | kills findings 2, 3 |
content-visibility: auto on transcript rows |
native off-screen skipping, 2.7× cheaper layout |
Follow-tail coalesced into one requestAnimationFrame |
removes the per-frame forced layout |
| Row patching left alone — measured at 0.0018 ms vs 0.0008 ms per row | not worth the complexity: ~0.006 % of a core at 2 rows/frame |
The transcript is bounded rather than windowed-behind-a-spacer. A spacer window was built and measured first, and the real-browser pass showed why it is the worse design: the scrollbar promises rows that cannot be shown, and a view parked at the top of the range has content evicted out from under it. Bounding the transcript the way a terminal bounds its scrollback gives the same O(1) cost with the browser owning the whole scroll interaction and nothing to correct.
4.4 Two bugs the real browser caught (both now regression-tested)
- Trimming detached follow-tail. Compensating the scroll offset while the
view is pinned to the tail leaves it a batch short of the bottom; the scroll
event that follows reads that as "the user scrolled away", so follow-tail
detached and — after a few trims — the view had drifted to the top of the
transcript. The offset must only be compensated when the view is NOT
following; there the browser's own clamp pins the new bottom.
(
TestClientJS_TranscriptTrimKeepsFollowTailArmed— verified to fail without the fix.) - The goja DOM stub clamped nothing.
scrollTopwas a plain property, so a page assigningscrollHeightlanded past the bottom and "is the view at the bottom" was a fiction. The stub now clamps like the real property, which is what makes the follow-tail assertions mean something.
5. Verification plan
- Go benchmarks
BenchmarkTakeScrollback/BenchmarkPublishCostmust be flat in history length (the regression detector for findings 1 and 7). Both now run up to 200 000 rows:PublishCostis 430 678 B / 541 allocs at 1 000 rows and 430 740 B / 543 allocs at 20 000 (was 508 140 → 1 698 846 B before the dirty-set fix). - Unit tests: ring eviction, absolute-base clamping, resume/attach after eviction, one-encode-per-frame (hub), no-client frame skip.
- Unit tests for finding 7 (
scrollback_cost_test.go): the dirty set holds one entry per screen row whatever the burst (2 479 entries at a 100-row burst before the fix), one frame after a 200 000-row burst stays inside a 1 MB allocation budget, and a resize re-sizes the set and repaints without shipping a stale row. - goja client tests: window bound, spacer height, rehydration, follow-tail coalescing, caret position unchanged.
- Real browser (
agent-browser): scrollbar geometry, wheel + follow-tail detach/re-arm, overscroll → browser history, cut/copy/paste, resize. e2e/w1_webui_browser.sh: add scroll/caret/resize/clipboard assertions so these are regression-checked, not hand-checked. (Done; the mechanics section was re-run against the fixed server — §2.7.)
5.1 Closed 2026-10-04: goa server did not exit on SIGINT/SIGTERM
goa server does not exit on SIGINT/SIGTERM: runWebServer builds the session
with signal.NotifyContext and then blocks in New(subs).Run(), which does not
observe that context, so the HTTP listener stops but the process stays up (and
the profiling flags never write their files). Reproduced on the binary built for
the §2.7 pass: kill -INT → kill -0 still true 5 s later. SIGTERM is ignored
the same way — cleaning up the throwaway servers left over from this work needed
kill -9 on eight processes that a pkill (SIGTERM) had left running.
Pre-existing, in the session lifecycle rather than the web layer; it is why the
§2 numbers come from benchmarks rather than an end-to-end pprof capture.
Fixed in the session lifecycle, not the web layer: the console's context now
reaches the stop path /quit already used (App.RunContext → StopRequest
control event → TUI.Stop), the listener closes only after the session ends, and
the process exits 0 with its profiles flushed. Real-binary PTY measurements
(Ctrl+C → exit 0 in ~2.8-3.1 s, SIGTERM likewise, profiles non-empty) are in
docs/archive/2026-10-04-webui-server-shutdown.md.