Web UI Profiling — CPU & Memory Playbook #
How to profile the web client, what to look for, and what we already know is expensive. The reported symptoms: the browser tab's memory can reach 1–2 GB and CPU spikes during agent activity. This document exists so the next investigation starts from evidence instead of guesses.
- Overlay:
src/web/lib/perf/overlay.ts+PerfOverlay.svelte(opt-in, zero cost when off) - Enable: append
?perf=1to the web UI URL (?perf=0/?perf=falseforce-off)
Profile the built app, not
vite dev. Dev-mode Svelte does far more work per update than production. Usebun run web:build+shard web(the daemon servesbuild/), open the printed URL, then append?perf=1. See DEVELOPMENT.md §2.
Quickstart — the whole loop in ten steps #
bun run web:build && shard web --web-port 8787 # 1. built app (never vite dev)
# 2. open the printed URL with ?perf=1&perf-record=1 # overlay + recording from load
- In the DevTools console:
__shardPerf.mark('baseline') - Run the §2 repro, marking every action:
__shardPerf.mark('paged to 5k'),('big doc'),('scrolled up'),('opened Recall'),('opened tabs'),('idle start')— end with ≥30s of idle (the leak detectors need it). - (CPU) While streaming, record a Performance-panel trace at 4–6× CPU throttle (§3.1) — alongside the recording, so timestamps line up.
__shardPerf.download()→shard-perf-<timestamp>.json.bun run perf:analyze shard-perf-*.json→ attribution + ranked findings (§1.2).- Follow each finding's
→line (decision tree, §1.2) to its §3 capture and §4 fix. - Fix, rebuild, re-record the same repro, re-run the analyzer, compare numbers.
- Memory findings: repeat with the §3.2 snapshot workflow instead of step 5.
Console surface: __shardPerf.{snapshot,startRecording,stopRecording,mark,export,download}.
1. The overlay #
With ?perf=1, +page.svelte mounts a fixed panel (bottom-right) fed by every
connection publication:
perf · ?perf=1
fps 58 · pub/s 42
md/s 38 · avg 6.2ms · max 180ms · in 41.2k
heap 684MB / 4096MB
dom 96.4k nodes
longtasks 12 · 2.4s
transcript 5.2k entries · 3 tabs
(When a recording is active, a rec hh:mm:ss · Ns line leads the panel — see §1.1.)
| Metric | Meaning | Healthy | Suspicious |
|---|---|---|---|
fps |
rAF frames in the last second | ~display refresh | <30 during interaction |
pub/s |
connection publications (state applications) | 0 idle; ≤60 streaming | high while idle (event storm) |
md/s |
renderMarkdown calls per second |
~pub/s at most |
≫ pub/s (duplicate re-renders) |
avg/max |
markdown render cost (marked + DOMPurify + highlight.js over the whole text) | <2ms | growing max as a message streams (O(n²) signature) |
in |
largest markdown input seen (chars) | small | ~full message size — every delta re-parses it |
heap |
JS heap (Chrome performance.memory; n/a elsewhere) |
<200MB | ratcheting up and not coming back down |
dom |
live DOM nodes | <20k | >50k (the transcript mounts every entry) |
longtasks |
>50ms main-thread blocks (count · total ms, 5s window) | 0 idle; few while streaming | dozens; seconds of blocking |
transcript |
summed transcript entries across open session tabs | — | grows with every opened session (see §5 M3) |
Rules of thumb for reading it:
- CPU high while streaming, tab otherwise responsive → look at
md/s/maxfirst (per-delta markdown re-parse, §4 F1), then longtasks. - Memory grows while paging back / opening sessions and never shrinks →
dom+heapratchet → retention problem (§4 M1–M3). Noteheapdoes not include DOM node storage — a tab can show 400MB heap and still hold 1GB+ of DOM/strings. - CPU high while the tab is idle → ambient animations (§4 F6) or an event storm
(
pub/s> 0 while nothing is happening).
The overlay is deliberately self-bounding (sample rings pruned at 60s/8192) — it cannot
become the leak it is hunting. dispose() removes the markdown telemetry hook.
1.1 Recording & export — turning the overlay into profiling data #
The live panel answers "what is happening now". To get data out for real analysis, run a recording. Recordings capture a per-second time series (publications, markdown renders + cost, long tasks, FPS, heap, DOM nodes) plus labeled annotations, and export as a self-contained JSON report.
Starting a recording
?perf=1&perf-record=1 # record from page load
or live, from the DevTools console (installed by start()):
__shardPerf.startRecording() // begin (fresh series)
__shardPerf.mark('opened Recall') // annotate the timeline
__shardPerf.stopRecording() // end (data stays exportable)
Getting the data out
__shardPerf.download() // saves shard-perf-<ISO timestamp>.json
__shardPerf.export() // returns the report object — pipe through DevTools `copy()`
copy(__shardPerf.export()) // → clipboard → paste anywhere
__shardPerf.snapshot() // live numbers, same as the overlay shows
Report format (shard-perf-report-v1): meta (URL, UA, epoch timestamps,
recording window), summary (totals/peaks: pubs, markdown ms + max input chars,
long tasks, heap peak MB, DOM peak, FPS min), series (one row per second —
{t, pubs, md, mdMs, mdMaxMs, mdMaxChars, longTasks, longTaskMs, fps, heapMb, dom},
zero-filled so gaps plot as zeros), and marks (labeled annotations, epoch ms).
Plotting one series without ceremony:
jq -r '.series[] | [.t, .pubs, .mdMs, .heapMb, .dom] | @tsv' shard-perf-*.json > perf.tsv
# → spreadsheet / gnuplot / matplotlib
python3 -c "import json,matplotlib.pyplot as plt;d=json.load(open('shard-perf-1.json'));s=d['series'];\
plt.plot([r['t'] for r in s],[r['heapMb'] for r in s],label='heapMB');\
plt.plot([r['t'] for r in s],[r['dom'] for r in s],label='dom nodes');plt.legend();plt.show()"
Overhead & bounds. Recording aggregates per second (events still flow through the
live rings untouched), so capture cost is negligible; markdown renders additionally
emit a User-Timing measure only while recording (see below). Series are capped at
24h of seconds; marks at 1000 (oldest dropped). Restarting a recording discards the
previous one — download before restarting. exportReport() zero-fills the tail up to
the export moment, so a main-thread-starved sampler cannot make the recording look
shorter than it was. Related: when the page is saturated you may see the overlay's
rec clock (a 500ms interval) freeze — that is the starvation making itself visible,
not lost data; the collector aggregates on the publication events themselves.
DevTools correlation. While recording, every renderMarkdown emits
performance.measure('shard:markdown', {start, duration}) — open the Performance
panel, record, and the per-render markdown cost appears in the Timings track next
to the flame chart, interleaved with long tasks and script samples. Both the report and
DevTools timestamps are wall-clock ms, so the JSON series and the captured trace
correlate directly: run a recording and a Performance-panel capture over the same
repro (§2), then use the JSON to find when it degraded and the trace to see why.
1.2 Analyzing a recording — from JSON to ranked bottlenecks #
The raw export is deliberately boring (per-second counters). The triage step is scripted, so analysis is two commands:
bun run perf:analyze shard-perf-*.json # or: bun scripts/analyze-perf.ts <file.json>…
It prints wall-time attribution, a ranked findings list whose codes map 1:1 onto the §4 catalog, and ±5s deltas around every mark. Full pipeline:
- Record a §2 repro with marks for each action (
__shardPerf.mark('opened Recall')). __shardPerf.download()→bun run perf:analyzeon the file.- Read the ranked findings; each one names the §4 code, the measured evidence, and the matching fix direction.
- For every non-OK finding, run the paired DevTools capture from §3 to get function-level attribution (the recording tells you when/what; the trace/snapshots tell you why), fix per §4, then re-record and compare the same numbers — the report is the before/after benchmark.
What the analyzer measures
- Attribution — % of wall time in markdown renders, in ≥50ms long tasks, and the remainder ("unattributed CPU" — long-task time markdown doesn't explain). Also pub/s, markdown renders per publication (≈1 healthy, ≫1 = amplification), min fps.
- F1 probe — per-char cost of the largest single render per second, compared
across a median split of input sizes (×≥2 flags) plus Pearson r against input size.
This is the O(n²) re-parse signature. It deliberately uses the single-render cost,
not per-second totals: once renders saturate the main thread, per-second totals pin
at ~1000ms/s and per-char totals fall while inputs keep growing — totals hide the
signature exactly when F1 is worst; a single render's cost stays measurable (see the
saturation test in
tests/web-perf-analysis.vitest.ts). - Idle detectors — fps<45 seconds while nothing renders: ≥80% of sampled idle seconds → sustained (F6, ambient animations); scattered → sporadic (F4, scroll-path scans). Longest idle run ≥30s gives heap MB/s and DOM nodes/s slopes (M1/M3).
- Memory release — after a ≥10s idle tail: how much of the activity-time heap rise was returned (low % = retention, M2/M3).
- Marks — each annotation with its ±5s metric deltas, so recorded actions line up with the degradation in the series.
Decision tree (analyzer output → meaning → next capture)
| Finding | Meaning | Confirms | Do next |
|---|---|---|---|
[F1] … — dominant |
render cost grows with streamed size | F1 | §4 F1 fix; confirm via §3.1 trace filtered to renderMarkdown |
[F2] unattributed CPU (+ high pub/s) |
≥50ms tasks beyond markdown | F2/F3 | §3.1 trace during streaming; weigh mergeLiveSnapshot/enrichTranscriptTools vs Svelte keyed-each diff |
[F2/F3] publication storm |
burst pubs + jank in those bursts, no long tasks, cheap markdown | F2/F3 amplified by pub rate | §3.1 trace of one burst, long-task filter off; msgs/s top: names the wire types; P1 client coalescing is in — if it still storms, batch live-event application per frame (P2) or daemon-side (§6.5) |
[F7] frames bound by DOM size |
frame time tracks DOM size (r≥0.55) at a steady pub rate | F3 rendering (style/layout/paint over the whole transcript), invisible to the long-task API | P3a is in (content-visibility rows); re-record — if still DOM-bound, virtualize; §3.1 trace in Chrome for the scripting/rendering split |
[F6] sustained idle jank |
frames slow while nothing renders | F6 | §3.1 Paint Flashing; pause the animation loops |
[F4] sporadic jank |
dips tied to scrolling | F4 | §3.1 trace while scrolling |
[M1] heap idle slope |
heap grows while idle | M1/M3 | §3.2 three-snapshot diff on the growth |
[M2] low released % |
activity memory not returned | M2/M3 | §3.2 active-vs-idle snapshots; close-tab test |
[M2-dom] dom idle slope |
detached nodes / retained tabs | M3 | §3.2 snapshot diff → detached node list |
[OK] |
nothing dominant in this window | — | re-run §2 with the missing actions (F5/Recall, scrolling) during the recording |
Sample output (synthetic 90s recording — streaming a large doc, then idle):
shard perf recording — triage
url: http://localhost:8787/?perf=1&perf-record=1 · 1m 30s · 90s samples · 3 marks
attribution (share of wall time)
markdown renders 61.7% (180 renders · avg 308.5ms · max input 96,400 chars)
long tasks ≥50ms 65.3% (240 tasks · avg 245ms)
unattributed CPU 3.6% (long-task time beyond markdown)
pub/s 4.0 avg · 100 peak · md/pub 0.50 · fps min 38
F1 probe: per-char cost growth ×2.90 · r(maxRenderMs, chars)=0.97
findings (ranked)
[F1] Markdown re-parses the full text per delta (O(n²) per message) — dominant
· per-char cost of the largest single render grew ×2.90 from small→large inputs
· max streamed input 96,400 chars · avg render 308.5ms over 180 renders
· markdown = 61.7% of wall time · 2.0/s renders
→ PROFILING.md §4 F1 — …
[F2/F3] Publication storm: frame budget starved by many sub-50ms per-publication tasks — notable
· pub/s peaked at 100 (avg 68.6); janky (fps<45) in 86% of 120 burst seconds
· fps min 11 ≈ 91ms frames vs a 16.7ms budget
· markdown 0.3% of wall · long tasks 0.0% — LongTask API silent
→ PROFILING.md §3.1 — record one burst with the long-task filter OFF …
[M1] Heap grows while idle — notable
· 2.20 MB/s over a 30s idle run (210→546 MB)
→ PROFILING.md §3.2 — …
[M2] Heap largely not released after activity stops — notable
· peak 546MB → last 546MB after a ≥10s idle tail; only 0.0% of the rise released
→ PROFILING.md §3.2 — …
marks
04:45:52 asked for big doc ±5s: pubs 66 · md 33 · longTaskMs 10890 · heap 323→368MB
Caveats. The series triages — only the trace/snapshots attribute to functions.
Thresholds are heuristics (constants at the top of src/web/lib/perf/analysis.ts);
tune them as real recordings accumulate. fps=0 seconds (hidden tab, rAF stopped) are
excluded from jank stats. The LongTask API only counts tasks ≥50ms — publication
storms chop work into 1–40ms tasks (invisible), and style/layout/paint from DOM diffing
never produces long tasks at all, so a "0 long tasks" line is not evidence of an
idle main thread; the fps series is the witness (that is what the [F2/F3] storm
finding keys on). mdMaxMs/mdMaxChars are independent per-second maxima, so the F1
pairing is a proxy — the trend across a median split is the signal, not any single
second.
2. Reproducing the worst case #
- Build + serve:
bun run web:build && shard web --web-port 8787; open the URL with?perf=1&perf-record=1(§1.1) so everything below lands in an exportable series. - Open a long-running session; repeatedly trigger load older until the overlay
shows
transcript ≥ 5k entries(each page prepends entries; nothing is virtualized, sodomclimbs with it).__shardPerf.mark('paged to 5k')as you go. - Start a task that streams a large artifact — e.g. ask the agent to write a long markdown document with several large fenced code blocks.
- While it streams: watch
md maxclimb (F1),longtasksaccumulate,fpssag. - Scroll up (leaves the at-bottom state) and scroll around: the scroll handler does a full-DOM scan per event (F4) — jank here is F4/F3, not F1.
- Open Recall (transcript search) and type: per-keystroke indexing of the whole transcript (F5).
- Open 3–4 more sessions as tabs, then re-check
heap/dom: per-tab transcript data is all retained (M3 — heap grows with tab count);domstays at the active pane's size (single mounted viewport, §5 M3). - For memory specifically: capture heap snapshots at steps 2, 3, and 7 (§3.2).
__shardPerf.download()thenbun run perf:analyze shard-perf-*.json(§1.2) — the ranked triage tells you which §3 capture to run for function-level attribution.
3. DevTools workflows #
3.1 CPU — Performance panel #
- DevTools → Performance → CPU throttle 4×–6× (emulates the slow machines users
actually report from) → record 10–20s during step 3 above. Keep the
?perf-recordrecording running concurrently — its JSON series (§1.1) marks when degradation happened, and this trace shows why; both use wall-clock ms. - Read the Bottom-Up view; the stack that matters (Streaming tab in a long session):
renderMarkdown→marked.parse/sanitize/hljs.highlight— F1mergeLiveSnapshot→collectTranscriptToolCalls/enrichTranscriptTools— F2- Svelte
flush/updatekeyed-each diff over every row — F3 visibleAnchorunder scroll events — F4rows()/JSON.stringifywith Recall open — F5
- While recording, per-render markdown cost also appears in the Timings track as
shard:markdownmeasures (§1.1) — scan it for the growth-over-time F1 signature. - Long tasks <50ms filter: everything the overlay counts appears here. But remember
the blind spots: (a) long tasks are only tasks ≥50ms — during publication storms
(burst pub/s with low fps and zero long tasks in the overlay) the main thread is
saturated by sub-50ms tasks the filter hides. In that case record a burst and read
the Summary tab per frame instead: Scripting share → F2 (state merge per
publication); Rendering share (style/layout/paint from diffing thousands of
mounted rows) → F3. Then fix the rate: coalesce/batch publications before
onUpdate(render-on-quiet) so per-frame work runs once per frame, not per message. (b) Firefox emits no long-task entries at all — the overlay's long-task counts and the analyzer's CPU attribution (F2/unattributed %) are structurally blind there; zero long tasks in a Firefox recording means unmeasured, not none. Record the same repro once in Chrome when you need the scripting-vs-rendering split, and rely on the F7 DOM-correlation detector for Firefox-only captures. - Rendering tab → Paint Flashing: any region flashing while idle is an ambient animation (F6 — reactor spins on session cards, ActivitySignal dashes). Also check Frame Rendering Stats while the SessionRack has many running sessions.
- Svelte DevTools: confirm whether a publication invalidates the whole
SessionDrawersubtree or just the streamingMessageText(prop identity churn inpaneState()is a candidate — new object literals per publication).
3.2 Memory — three-snapshot + allocation timeline #
Chrome (or a Chromium build) is the only browser with the full memory toolkit:
performance.memory exists there, so the ?perf-record harness itself gains heapMb
columns — the cheapest first pass is simply re-running the same §2 recording in
Chrome and running the analyzer: the M1 (idle heap slope) and M2 (released %)
detectors fire automatically on thresholds. Firefox gives null heap columns; its
snapshots and the LongTask API are structurally absent (§3.1 4b).
Chrome gotchas: use a clean profile / guest window (extensions inflate heap + DOM
and will pollute every diff); performance.memory numbers are quantized (bucketed,
~±5%) — fine for slopes and before/after deltas, not for exact sizes (launch with
--enable-precise-memory-info if you need exact); DOM and compositor memory live
outside the JS heap, so DevTools heap numbers can look small next to Task Manager
— cross-check there and via chrome://tracing (§3.3). During snapshots, annotate
phases in the recording with window.__shardPerf.mark('label') so the JSON series
correlates with what you did.
(The ?perf-record JSON series gives you the when for free: correlate heapMb/dom
trends in the export with the actions you annotated via __shardPerf.mark().)
- Memory panel → heap snapshot at rest (t₀).
- Do the suspected action (page back N times / open K sessions / stream one big message).
- Snapshot again (t₁), optionally a third after forcing GC (🗑 button).
- Comparison view t₁ vs t₀, sorted by
Size Delta, filtered to#added:TranscriptEntry/ToolEventobjects + theircontentstrings → M1 (full tool output is retained by design for the dedicated viewer)- huge
stringcounts → markdown intermediate HTML + retained message/tool text HTMLSpanElement/Textnodes → DOM growth from highlight trees, F1's output
- Allocation instrumentation on timeline: record while streaming; the per-delta churn (new transcript arrays from immutable merges, concatenated strings, HTML strings) shows as short-lived blue bars — churn itself is GC pressure, sustained growth of retained (grey) objects is the leak.
- Detached check: filter snapshot for detached
HTMLLIElement/HTMLSpanElement. If large detached trees exist, something holds references to removed DOM (not currently suspected — FlightPlan keyed-each reuses nodes — but verify after any rendering change). - Retention chain drill-down: click a fat
ToolEventstring → Retainers pane → expecttab.transcript→sessionTabs→WebConnectionState. That confirms which retention path to fix (M1/M3).
3.3 Cross-checks #
chrome://tracing/about:tracingfor renderer-process memory when DevTools heap numbers seem too small (DOM lives outside the JS heap).- Task Manager (Shift+Esc) → per-tab "Memory" + "GPU memory": the 1–2GB number users report is usually here, i.e. including DOM/compositor — consistent with the no-virtualization hypothesis (M2), not necessarily with JS leaks.
performance.measureUserAgentSpecificMemoryis not available cross-origin-isolated here;performance.memory(Chrome) is what the overlay uses.
4. Known hot paths (ranked, with expected profile signatures) #
These are static-analysis findings from the current code, each with the signature that confirms or clears it in a profile. Fixing them is deliberately not part of this doc — measure first, then pick off what the profile actually shows.
CPU #
F1 — Full markdown re-parse per streaming delta (prime suspect).
MessageText.svelte ($: rendered = renderMarkdown(text, { streaming })) re-runs
marked + DOMPurify + highlight.js over the entire accumulated block text on every
publication in which that text changed. Streamed text appends per delta
(mergeLiveEntry: previous.text + next.text), so per-message cost is O(n²) in the
final text length — a 100KB message streamed in ~500 deltas re-parses ~50MB of
markdown cumulatively, each pass also re-highlighting the full (growing) code fence.
Signature: md/s ≈ pub/s, md max grows monotonically while one message streams;
flame chart dominated by marked.parse/sanitize/hljs.highlight.
Directions: parse-on-paint with coalescing keyed to visible tail; incremental
re-parse of only the trailing paragraph/fence; cache the rendered prefix before the
last blank line / open fence; pause re-parse when the block is offscreen.
F2 — O(transcript) state work per delta.
Every live delta runs mergeLiveSnapshot (session-history.ts): findLiveMatch per
existing entry, then enrichTranscriptTools → collectTranscriptToolCalls walks all
entries and content blocks rebuilding a Map, then maps all entries again. Also
sequenceUnkeyedSystems builds a Set of all system entries. All O(n) with allocations
per delta. Signature: mergeLiveSnapshot/collectTranscriptToolCalls visible in
bottom-up at streaming time; worse the longer the session. Directions: maintain the
tool-call index incrementally instead of rebuilding; short-circuit enrichment for
pure text deltas.
F3 — No virtualization: keyed-each diff over every row per publication.
FlightPlan.svelte {#each transcript as entry, i (transcriptKey(entry))} — Svelte
computes keys for all N entries on every publication and diffs them; every row's DOM
is mounted (also the memory driver, M2). Signature: Svelte update cost proportional
to N in the flame chart even though only the last row changed. Directions:
windowed rendering (render only entries near the viewport — the viewport already
tracks anchors), or at minimum a capped live tail + virtualized history.
Amplifier — publication rate (storm). The F2/F3 per-publication cost is multiplied
by the publication rate. A real capture recorded bursts of 72–100 pubs/s (session
open hydration, live streaming, split-view sync) with fps collapsing to 11 while long
tasks stayed at zero — per-publication work runs in 1–40ms slices, so the LongTask
API never fires but the 16.7ms frame budget is gone. Overlay signature: pub/s avg
tens, peaks ~100, fps dips tracking the bursts, longtasks 0, cheap markdown. The
first-order fix is rate, not per-call cost: coalesce/batch publications before
onUpdate (e.g. rAF-batched or 30–60ms trailing-edge throttle — render-on-quiet), so
F2/F3 run once per frame instead of once per message. The [F2/F3] storm finding in
§1.2 detects this from a recording.
F4 — Scroll handler does full-DOM scans.
SessionTranscriptViewport.svelte: when not at bottom, every scroll event runs
visibleAnchor() (querySelectorAll('[data-entry-id]') + offsetTop reads → forced
layout) and emits state; handleTranscriptChange does the same walk after each
transcript change off-bottom. With 10k+ rows this is jank on every wheel tick.
Signature: visibleAnchor/layout under scroll handlers. Directions: rAF-throttle
scroll handling; track the anchor incrementally from the last known index.
F5 — Recall rebuilds a full-text index of the transcript.
Recall.svelte: $: all = rows(transcript) stringifies every tool result into a
Row (re-runs on every publication while open), and every keystroke lowercases + scans
all row texts. With a long transcript and Recall open during streaming this dominates.
Signature: rows/JSON.stringify per publication; per-keystroke freeze.
Directions: build the index lazily/memoized per transcript identity; search without
full lowercase copies (e.g. scan with a case-insensitive regex on demand, or debounce).
F6 — Ambient animations.
Per-card ShardReactor spin (SessionRack cards), ActivitySignal SVG dash/cursor
loops, wp-pulse on running tool rows, FlightPlan's 1s ticker while tools are
in-flight (elapsedByTool recompute). Small each, but they burn CPU continuously and
scale with visible cards/rows. Signature: paint flashing while idle; composited
frame cost. Directions: content-visibility, pause when document.hidden or the
pane is offscreen, respect reduced-motion globally (partially done).
F7 — Frames bound by DOM size (rendering, long-task-invisible).
Every coalesced flush mutates text inside a transcript whose whole document is
laid out: style recalculation + layout + paint scale with total mounted DOM, not
with what changed. Recorded (2026-08-31): steady 20fps plateaus on an ~18.7k-node
transcript vs 60fps on ~3.7k at the identical message rate — r(domNodes, frameMs) =
0.73, ~38ms/frame ≥10k nodes vs ~17ms below. Firefox emits no long-task entries,
so task-based attribution shows 0% — the analyzer flags this via the DOM correlation
instead (F7). Signature: fps floor ∝ transcript size at constant pub rate; §3.1
trace shows Rendering (purple/green) dominating, near-zero Scripting.
Directions: done — P3a (content-visibility: auto rows + measure pass in
FlightPlan/SessionTranscriptViewport). If still DOM-bound after re-recording:
full virtualization (window the keyed-each to entries near the viewport).
Memory #
M1 — Full tool output retained per entry (by design, unbounded in aggregate).
ToolEvent.content keeps the complete result text ("the full data stays on the entry
for the dedicated viewer / copy" — transcript.ts). A session that read many large
files/outputs retains all of it for the session's lifetime in the tab. Inline display
is bounded (24 lines/8k chars), so this is invisible in the UI while dominating heap.
Signature: fat string retention under ToolEvent in snapshots.
Directions: keep full output behind a session-scoped LRU/refetch (session.entry
replay already exists daemon-side), or cap retained bytes per tab.
M2 — Every transcript entry is live DOM.
No virtualization (F3): markdown HTML with highlight.js span trees for every code
block, a <details> per tool row, etc. Ballpark: hundreds of nodes per rich entry →
a 5k-entry session is easily 10⁵–10⁶ nodes; at ~0.5–1.5KB per node that alone explains
the 1–2GB reports. Fixing F3 fixes this; the overlay's dom number is the direct
measurement.
M3 — Every opened session tab retains its full transcript (data, not DOM).
Tabs are keyed by session in WebConnectionState.sessionTabs; switching sessions does
not evict (only explicit close does, closeSessionTab). Open tabs persist to
localStorage and are re-opened (and re-replayed) on reload. Ten opened long sessions =
ten full transcripts (entries + full tool-output strings). DOM is per visible pane,
not per tab: SessionDrawer mounts one SessionTranscriptViewport for the active
session (+1 in split mode) under {#key activeId} — background tabs hold data only.
Signature: heap grows with the number of tabs opened; DOM does not (it
scales with visible panes — see M2). Verified (2026-08-31 Chrome trace): closing the
19.7k-node session dropped dom 19.7k→4.8k and heap 86→29MB within ~2s; teardown is
clean. Remount cost: {#key activeId} rebuilds the full mounted DOM on every tab
switch — the cold-open jank (fps 17–25 opening the big session) is this remount,
softened by the markdown memo + measure pass. Directions: cap retained tool output
per entry (the M1 lever), evict inactive tabs' transcripts (keep the newest page +
viewport anchor; re-replay on activation — the activation buffer machinery already
supports this), or cap total retained entries. Windowing the viewport (P2/F3) cuts
both the visible-pane DOM and the remount cost.
M4 — Per-delta allocation churn (GC pressure, not a leak). Immutable merges build a new transcript array + entry objects + accumulated strings per delta; markdown produces full HTML strings per render. Transient, but at 30–60 publications/s over long sessions it forces frequent major GCs (visible as frame spikes in the Performance panel's Memory track). Fixing F1/F2 reduces it as a side effect; no independent action needed now.
Already guarded (do not re-blame these) #
- Publications are frame-coalesced for streaming fragments (
setStreaming, one markdown pass per paint) — connection.ts. Immediate publication happens only on completion/tool/permission/lifecycle boundaries. - Inline tool output and diffs are bounded previews (24 lines / 8k chars; diffs capped at 8 dense lines); expansion is explicit.
- Activation buffer is capped (256 records / 1MiB) — connection.ts.
- Viewport persistence is capped (MAX_VIEWPORTS = 50) — +page.svelte.
argsTextaccumulation is cleared when a tool call completes — mergeToolEvents.- highlight.js uses a curated language set (bounded bundle), unknown languages degrade to escaped plain text.
5. Adding future instrumentation #
- The overlay subscribes to two signals today: publications (
onPublicationin+page.svelte#onUpdate) and markdown renders (setMarkdownHookinsrc/web/lib/markdown.ts). To watch another transform (e.g.mergeLiveSnapshotcost), add a siblingsetXHook+onX(input, ms)pair and a snapshot field — keep the same rules: opt-in, guarded, self-bounding. Feed it into the recording buckets the same way (if (recording) bucketFor(t).x++) so it lands in exported reports. - For one-off measurements use User Timing directly in a scratch change:
performance.mark('x-start')/performance.measure('x', 'x-start')— marks show up in the Performance panel's Timings track and are trivial to delete. The markdown hook already does this while recording (shard:markdown). - The
window.__shardPerfconsole surface (§1.1) is installed bystart()and removed bydispose()— extendShardPerfConsole(overlay.ts) if a new control is needed. - jsdom-safe unit tests for collector behavior (including recording/export) live in
tests/web-perf-overlay.vitest.ts(included inbun run test:web).
6. Order of attack (from the first recorded capture) #
The first real recording (session-open → stream → split-view, 3 min) re-ranked the
catalog: fps min 11 during publication bursts of 72–100/s, zero long tasks, 0.3%
markdown — the LongTask API is blind to sub-50ms per-publication work (§1.2
caveats), so the storm is invisible to task-based detectors. Frame load ≈
C × rate, where C = per-publication work (O(transcript) merge F2 + O(rows)
diff/layout F3) and rate is the burst multiplier. Fix the multiplier first, then C —
and treat memory (mounted DOM) as its own track, since rate fixes don't shrink it.
- Trace one burst before coding (§3.1 step 4): Performance panel, long-task filter off, CPU 4×, record a session-open burst. Summary per frame: Scripting-heavy → F2 is the first per-call fix; Rendering-heavy (style/layout/paint) → F3 is. Also check how much time is JSON parsing — if message decode itself is significant, daemon-side batching becomes relevant (§6.6).
- P1 — Coalesce publications (render-on-quiet). Implemented, verified.
WebConnectionnow coalesces every steady-state subscriber notification to the next frame (rAF, opt-in viadeps.requestFrame, which+page.svelteprovides); phase transitions (error/offline/enrollment/connecting/connected) still publish immediately, and protocol state stays synchronous + lossless — only the notification defers. Wire-type instrumentation (deps.onMessage→__shardPerfrecording,msgs/s top:in the analyzer) shows what traffic is made of. Measured (2026-08-31 re-record, same repro): pub/s peak 121 → 26, avg 45.8 → 10.6; the [F2/F3] storm finding is gone. The residual dips are a different bottleneck (F7, below). Still open (P2):mergeLiveSnapshot+enrichTranscriptToolsstill run per message — batch the live-event application per frame if a Chrome trace shows scripting-long-tasks. - P3a — content-visibility on transcript rows. Implemented. The second recording
isolated the residual dips: at a constant publication rate (~20 pubs/s, ~130 msgs/s),
frame time tracked DOM size — r(dom, frameMs) = 0.73, ~38ms/frame at ≥10k nodes vs
~17ms below, rock-steady 20fps plateaus on an 18.7k-node transcript, 60fps on a 3.7k
one (F7, now auto-detected by the analyzer). Firefox emits no long-task entries,
so this rendering cost was invisible to task-based attribution. Fix: FlightPlan rows
carry
content-visibility: auto+contain-intrinsic-size: auto 64px— offscreen rows skip style/layout/paint entirely. Because placeholder heights would corrupt the viewport's offset-based scroll restores (the prepend no-jump guarantee, saved-anchor restore), the viewport arms a measure pass around head changes (initial load, older-page prepend, session switch): one full-layout frame with skipping disabled, after whichcontain-intrinsic-size: autoremembers every row's real size and the skipping re-arms. Expect: streaming frame time decoupled from transcript size. - P3b — markdown render memo. Implemented. The same recording showed 150–374
parse calls/s in burst seconds where only 1–2 blocks had changed (row remounts on
message_complete+ sibling block churn after a live merge re-render unchangedMessageTexts; a single 34k-char re-parse costs up to ~18ms).renderMarkdownis now memoized in a bounded LRU (512 entries / 4M input chars, keyed on text+streaming) — unchanged blocks become ~0ms cache hits; the hook still fires per call so counts separate real parse work from churn. - P2 — per-publication scripting cost, only if a Chrome §3.1 trace (Firefox has no long-task API) still shows scripting long-tasks during bursts: incremental tool-call index + skip enrichment for pure-text deltas (F2), and/or transcript windowing — render only entries near the viewport (F3/memory: visible-pane DOM stops scaling with transcript length — the M2 driver. Per-tab data retention is M3: tool-output caps / page eviction).
- Daemon-side batching (only if the trace shows decode cost): batch stream deltas
into fewer WebSocket frames (e.g. 30–50ms flush) so the client parses once per
batch. Client coalescing cannot remove per-message parse cost; this can. The
recording's
msgs/s top:line (~21/s each forsession_runtime_changed,agent_activity,message_deltain the 2026-08-31 capture) says the daemon sends ~130 msgs/s during streaming — if P2 scripting cost matters, this is the lever. - Verify each step with the harness: same §2 repro,
?perf=1&perf-record=1,bun run perf:analyzebefore/after. Acceptance: no storm/F7 finding; fps min ≥ 45 during bursts (≥ 30 at 4× throttle); after windowing,domnodes ≈ viewport-sized and heap no longer scales with transcript length × open tabs.
Re-rank after every trace — this order reflects one capture, not a law.