# 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=1`** to the web UI URL (`?perf=0`/`?perf=false` force-off) > **Profile the built app, not `vite dev`.** Dev-mode Svelte does far more work per > update than production. Use `bun run web:build` + `shard web` (the daemon serves > `build/`), open the printed URL, then append `?perf=1`. See DEVELOPMENT.md §2. ## Quickstart — the whole loop in ten steps ```sh 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 ``` 3. In the DevTools console: `__shardPerf.mark('baseline')` 4. 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). 5. (CPU) While streaming, record a Performance-panel trace at 4–6× CPU throttle (§3.1) — alongside the recording, so timestamps line up. 6. `__shardPerf.download()` → `shard-perf-.json`. 7. `bun run perf:analyze shard-perf-*.json` → attribution + ranked findings (§1.2). 8. Follow each finding's `→` line (decision tree, §1.2) to its §3 capture and §4 fix. 9. Fix, rebuild, re-record the **same** repro, re-run the analyzer, compare numbers. 10. 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`/`max` first (per-delta markdown re-parse, §4 F1), then longtasks. - **Memory grows while paging back / opening sessions and never shrinks** → `dom` + `heap` ratchet → retention problem (§4 M1–M3). Note `heap` does 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()`): ```js __shardPerf.startRecording() // begin (fresh series) __shardPerf.mark('opened Recall') // annotate the timeline __shardPerf.stopRecording() // end (data stays exportable) ``` **Getting the data out** ```js __shardPerf.download() // saves shard-perf-.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: ```sh 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: ```sh bun run perf:analyze shard-perf-*.json # or: bun scripts/analyze-perf.ts … ``` 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: 1. Record a §2 repro **with marks** for each action (`__shardPerf.mark('opened Recall')`). 2. `__shardPerf.download()` → `bun run perf:analyze` on the file. 3. Read the ranked findings; each one names the §4 code, the measured evidence, and the matching fix direction. 4. 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 1. 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. 2. Open a long-running session; repeatedly trigger **load older** until the overlay shows `transcript ≥ 5k entries` (each page prepends entries; nothing is virtualized, so `dom` climbs with it). `__shardPerf.mark('paged to 5k')` as you go. 3. 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. 4. While it streams: watch `md max` climb (F1), `longtasks` accumulate, `fps` sag. 5. 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. 6. Open **Recall** (transcript search) and type: per-keystroke indexing of the whole transcript (F5). 7. 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); `dom` stays at the active pane's size (single mounted viewport, §5 M3). 8. For memory specifically: capture heap snapshots at steps 2, 3, and 7 (§3.2). 9. `__shardPerf.download()` then `bun 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 1. DevTools → Performance → CPU throttle **4×–6×** (emulates the slow machines users actually report from) → record 10–20s during step 3 above. Keep the `?perf-record` recording running concurrently — its JSON series (§1.1) marks *when* degradation happened, and this trace shows *why*; both use wall-clock ms. 2. Read the **Bottom-Up** view; the stack that matters (Streaming tab in a long session): - `renderMarkdown` → `marked.parse` / `sanitize` / `hljs.highlight` — F1 - `mergeLiveSnapshot` → `collectTranscriptToolCalls` / `enrichTranscriptTools` — F2 - Svelte `flush`/`update` keyed-each diff over every row — F3 - `visibleAnchor` under scroll events — F4 - `rows()` / `JSON.stringify` with Recall open — F5 3. While recording, per-render markdown cost also appears in the **Timings** track as `shard:markdown` measures (§1.1) — scan it for the growth-over-time F1 signature. 4. 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. 5. 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. 6. Svelte DevTools: confirm whether a publication invalidates the whole `SessionDrawer` subtree or just the streaming `MessageText` (prop identity churn in `paneState()` 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()`.) 1. Memory panel → heap snapshot at rest (t₀). 2. Do the suspected action (page back N times / open K sessions / stream one big message). 3. Snapshot again (t₁), optionally a third after forcing GC (🗑 button). 4. **Comparison** view t₁ vs t₀, sorted by `Size Delta`, filtered to `#` added: - `TranscriptEntry` / `ToolEvent` objects + their `content` strings → M1 (full tool output is retained by design for the dedicated viewer) - huge `string` counts → markdown intermediate HTML + retained message/tool text - `HTMLSpanElement` / `Text` nodes → DOM growth from highlight trees, F1's output 5. **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. 6. **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). 7. Retention chain drill-down: click a fat `ToolEvent` string → Retainers pane → expect `tab.transcript` → `sessionTabs` → `WebConnectionState`. That confirms which retention path to fix (M1/M3). ### 3.3 Cross-checks - `chrome://tracing` / `about:tracing` for 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.measureUserAgentSpecificMemory` is 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 `
` 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. - `argsText` accumulation 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 (`onPublication` in `+page.svelte#onUpdate`) and markdown renders (`setMarkdownHook` in `src/web/lib/markdown.ts`). To watch another transform (e.g. `mergeLiveSnapshot` cost), add a sibling `setXHook` + `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.__shardPerf` console surface (§1.1) is installed by `start()` and removed by `dispose()` — extend `ShardPerfConsole` (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 in `bun 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. 1. **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). 2. **P1 — Coalesce publications (render-on-quiet).** *Implemented, verified.* `WebConnection` now coalesces **every** steady-state subscriber notification to the next frame (rAF, opt-in via `deps.requestFrame`, which `+page.svelte` provides); **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` → `__shardPerf` recording, `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` + `enrichTranscriptTools` still run per message — batch the live-event application per frame if a Chrome trace shows scripting-long-tasks. 3. **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 which `contain-intrinsic-size: auto` remembers every row's real size and the skipping re-arms. Expect: streaming frame time decoupled from transcript size. 4. **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 unchanged `MessageText`s; a single 34k-char re-parse costs up to ~18ms). `renderMarkdown` is 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. 5. **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). 6. **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 for `session_runtime_changed`, `agent_activity`, `message_delta` in the 2026-08-31 capture) says the daemon sends ~130 msgs/s during streaming — if P2 scripting cost matters, this is the lever. 7. **Verify each step with the harness**: same §2 repro, `?perf=1&perf-record=1`, `bun run perf:analyze` before/after. Acceptance: no storm/F7 finding; fps min ≥ 45 during bursts (≥ 30 at 4× throttle); after windowing, `dom` nodes ≈ 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.