Summary
When the WebUI WebSocket reconnects (e.g. after the browser tab was backgrounded long enough for the socket to drop), the client sends state_request with forceFull: true. The server returns the entire chat log, applySnapshot() wipes the DOM via resetMessageRenderState(), and renderMessageBatch() re-renders every message sequentially (markdown parse, syntax highlight, KVP, DOM insert — one await per message).
On large chats (200+ messages) this freezes the UI for ~30-60 seconds. A full page refresh (F5) is dramatically faster than the reconnect replay, because the browser parses bulk HTML natively instead of the JS render loop building it message by message.
This behavior came with the sync layer (webui/components/sync/sync-store.js) — before it, returning to an inactive tab did not trigger a full re-render.
Reproduction
- Open a chat with a large history (200+ messages).
- Let the WebSocket drop (background the tab until the socket is reaped, or toggle network offline/online).
- Return to the tab: socket reconnects, loading splash, messages re-render one by one, UI unusable ~30-60s.
Note: the expensive path only fires when log_version changed while the tab was away (agent produced messages during the disconnect). If nothing changed, applySnapshot skips rendering and reconnect is instant — which makes the freeze intermittent and harder to bisect.
Root cause
webui/components/sync/sync-store.js, in the stateSocket.onConnect handler:
// Always re-handshake on every Socket.IO connect. ...
// ... pushes are gated ... so logs appear to stall.
this.sendStateRequest({ forceFull: true })
forceFull: true -> buildStateRequestPayload sets log_from: 0 -> server returns the full log -> applySnapshot() (webui/index.js):
if (lastLogVersion != snapshot.log_version) {
updated = true;
if (snapshot.logs?.[0]?.no === 0) {
msgs.resetMessageRenderState(); // DOM wipe
}
await setMessages(...); // sequential re-render of ALL messages
}
The code comment justifies re-handshaking on every connect (the backend StateMonitor is per-sid and gates pushes until a fresh state_request) — that part is correct. But the handshake does not require forceFull. The client already tracks lastLogVersion / lastLogGuid, and three independent safety nets already catch divergence:
log_guid mismatch -> automatic reset + resync
runtime_epoch mismatch -> automatic resync
- push sequence-gap detection -> automatic resync
Proposed fix (tested locally)
Make the reconnect handshake incremental — in sync-store.js onConnect:
this.sendStateRequest({ forceFull: false })
Behavior after the change:
| Scenario |
Before |
After |
| Reconnect, nothing changed |
Full log -> DOM wipe -> re-render all (~1 min freeze) |
log_version unchanged -> no render -> instant |
| Reconnect, new messages |
Full freeze |
Only the delta renders -> fast |
| Chat reset while away |
Full resync |
Full resync — caught by log_guid check |
| Server restarted |
Full resync |
Full resync — caught by runtime_epoch check |
| Fresh page load |
Full load |
Identical — lastLogVersion is 0 so log_from: 0 either way |
I have been running this as a 1-line local patch; reconnects after 10+ minutes of background throttling are now instant, with no divergence the safety nets did not catch.
More conservative alternative
Keep the forceFull handshake (wire protocol unchanged) but make applySnapshot delta-aware: when a full snapshot arrives for the same log_guid and lastLogVersion > 0, skip resetMessageRenderState() and render only messages with no >= lastLogVersion.
Environment
- Agent Zero: current Docker image (
agent0ai/agent-zero), self-hosted
- Browser: Brave (Chromium) on Linux — background-tab throttling drops the socket, triggering the reconnect path
Summary
When the WebUI WebSocket reconnects (e.g. after the browser tab was backgrounded long enough for the socket to drop), the client sends
state_requestwithforceFull: true. The server returns the entire chat log,applySnapshot()wipes the DOM viaresetMessageRenderState(), andrenderMessageBatch()re-renders every message sequentially (markdown parse, syntax highlight, KVP, DOM insert — oneawaitper message).On large chats (200+ messages) this freezes the UI for ~30-60 seconds. A full page refresh (F5) is dramatically faster than the reconnect replay, because the browser parses bulk HTML natively instead of the JS render loop building it message by message.
This behavior came with the sync layer (
webui/components/sync/sync-store.js) — before it, returning to an inactive tab did not trigger a full re-render.Reproduction
Note: the expensive path only fires when
log_versionchanged while the tab was away (agent produced messages during the disconnect). If nothing changed,applySnapshotskips rendering and reconnect is instant — which makes the freeze intermittent and harder to bisect.Root cause
webui/components/sync/sync-store.js, in thestateSocket.onConnecthandler:forceFull: true->buildStateRequestPayloadsetslog_from: 0-> server returns the full log ->applySnapshot()(webui/index.js):The code comment justifies re-handshaking on every connect (the backend
StateMonitoris per-sid and gates pushes until a freshstate_request) — that part is correct. But the handshake does not requireforceFull. The client already trackslastLogVersion/lastLogGuid, and three independent safety nets already catch divergence:log_guidmismatch -> automatic reset + resyncruntime_epochmismatch -> automatic resyncProposed fix (tested locally)
Make the reconnect handshake incremental — in
sync-store.jsonConnect:Behavior after the change:
log_versionunchanged -> no render -> instantlog_guidcheckruntime_epochchecklastLogVersionis 0 solog_from: 0either wayI have been running this as a 1-line local patch; reconnects after 10+ minutes of background throttling are now instant, with no divergence the safety nets did not catch.
More conservative alternative
Keep the
forceFullhandshake (wire protocol unchanged) but makeapplySnapshotdelta-aware: when a full snapshot arrives for the samelog_guidandlastLogVersion > 0, skipresetMessageRenderState()and render only messages withno >= lastLogVersion.Environment
agent0ai/agent-zero), self-hosted