perf(transcript): default-OFF DOM virtualization means long sessions render O(N) nodes and full-rebuild on every update

Author: rh-idCreated Jul 16, 2026Updated Sep 17, 2026
Labelsenhancementrendererperformance

Thinking Path

  • Hermes WebUI streams live chat turns efficiently (incremental markdown parser, throttled ~15fps token append), and the normal session-open path fetches a bounded ~30-message tail window.
  • But transcript rendering into the DOM has no windowing by default: every renderable message becomes a DOM node that stays in the tree, and renderMessages() does a full wipe-and-rebuild on every in-session update.
  • A virtualization subsystem exists (MESSAGE_VIRTUAL_THRESHOLD_ROWS, MESSAGE_RENDER_WINDOW_DEFAULT) but is intentionally OFF by default (#4343, for scroll-up flicker). So a long conversation is O(N) DOM nodes and O(N) work per update.
  • This is distinct from #5839 (reasoning/journal-specific blowup, closed) — it's the general case: any long session, any model.

What's happening

1. No DOM windowing by default — O(N) nodes for the whole transcript.

Transcript virtualization is experimental and default-OFF:

  • static/boot.js:3165-3168#4343: transcript virtualization is EXPERIMENTAL/opt-IN (default OFF)... window._virtualizeTranscript=s.virtualize_transcript===true;
  • static/boot.js:3319 — on settings-load failure, window._virtualizeTranscript=false; (default OFF).
  • static/ui.js:826-836_currentMessageVirtualWindow() short-circuits to a fully non-windowed result when virtualization is off: returns {virtualized:false, start:0, end:total, ...} — i.e. the whole visWithIdx.
  • The render loop at static/ui.js:15533+ then builds a <div class="msg-row"> for every message, all appended to #msgInner. Node recycling (_msgNodeRecycleEnabled, _recycleStash) only activates inside the virtualized branch.

So in the default configuration, a 10,000-message conversation produces 10,000 DOM nodes that all stay in the tree. Layout/style/paint cost grows with total transcript length, not viewport size.

2. renderMessages() is a full wipe-and-rebuild, called on every in-session update.

  • static/ui.js:15357inner.innerHTML=''; (the wipe), then the rebuild loop re-parses markdown (_getCachedRender) and reconstructs every row's HTML.
  • It's called from 113 sites across the frontend (grep renderMessages( static/*.js | wc -l), including every: tool completion (static/messages.js:5860, 6138, 6143, 6344, 6374, 6523, 6587, 6633, 8148, 8594), stream done/settle, edit, error, and new user message.
  • Each such call is O(N) over the whole transcript. On a large session, a single tool finishing re-renders every prior message row on the main thread.

There IS a per-session HTML cache (_sessionHtmlCache, static/ui.js:14334, LRU-bounded at 8 entries × 300KB) that helps on switch-back-to-a-previously-rendered-session, but it's bypassed while streaming and doesn't help in-session updates.

Why it matters

Memory and responsiveness scale with total transcript length, not the viewport. On a long agentic session (hundreds–thousands of messages, each possibly carrying large tool outputs), the default config holds every node in the DOM and re-renders all of them on each tool/stream-done event. This is the structural cause behind the "browser freeze on long sessions" class of reports (cf. #5839, #4325, #5636, #4277).

What already exists (so this is a re-enable + stabilize task, not greenfield)

  • Windowing math: _messageVirtualWindow() (static/ui.js:836+) — computes a visible slice from scrollTop/viewportHeight/heights.
  • Threshold + window size: MESSAGE_VIRTUAL_THRESHOLD_ROWS=80, MESSAGE_RENDER_WINDOW_DEFAULT=50 (static/ui.js:524-525).
  • Height cache: _messageVirtualHeightCache (static/ui.js:542), cleared on session change.
  • Top/bottom padding spacers for scroll position preservation.
  • Node recycling stash (_recycleStash).

So the machinery is there. The blocker to defaulting it back ON is the scroll-up flicker / oscillation that #4343 disabled it for (and #4346's Phase B root-cause fix is closed — worth checking whether the variable-height-anchor oscillation it targeted is actually resolved).

Suggested direction (not prescribing)

  1. Revisit why #4343 defaulted it off — is the #4346 Phase B fix (variable-height anchor oscillation) actually resolving the flicker, or was it closed without re-enabling?
  2. If the flicker root cause is fixed, re-default virtualization ON behind the existing threshold (MESSAGE_VIRTUAL_THRESHOLD_ROWS=80) so only long sessions pay the windowing complexity.
  3. As a smaller intermediate step even with virtualization off: cap the rendered DOM to the tail window the server already sends (msg_limit) and gate older rows behind the existing "Load earlier" button (static/ui.js:1531) — the infrastructure is present but only wired when virtualization is on.

Verification I did (evidence)

  • Confirmed window._virtualizeTranscript===false is the default on both the success and settings-failure paths (boot.js:3168, 3319).
  • Confirmed the OFF path returns start:0, end:total (ui.js:829-835).
  • Confirmed renderMessages wipes and rebuilds (ui.js:15357) and is called from 113 sites.
  • Confirmed streaming tokens do NOT go through renderMessages per-token (they append to one live node via window.smd), so the per-token path is fine — it's the per-update full re-render that's costly.

Scope note

This is a frontend rendering concern, separate from the backend bounded-read work (a server-side msg_limit ceiling and bounded state.db read are being addressed separately). Even with backend pagination, the DOM still holds everything the server sent unless virtualization/windowing is enabled — so both layers need bounding for the long-session case.

Model Used

builtin:zai-coding-plan/GLM-5.2 (ZCode agent). Audit done by reading static/boot.js, static/ui.js, static/messages.js, static/sessions.js against the current master.