Skip to content

WebUI freezes ~1 min on tab reconnect: full-log resync re-renders every message sequentially (reconnect should be delta-aware) #1839

Description

@nico7master

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

  1. Open a chat with a large history (200+ messages).
  2. Let the WebSocket drop (background the tab until the socket is reaped, or toggle network offline/online).
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions