Skip to content

[Bug]: Reopening a thread with a large event backlog freezes the UI while it visibly re-replays (quadratic replay) #4596

Closed

Description

@colonelpanic8

Summary

When enableAssistantStreaming is on, every provider chunk is persisted as its own
thread.message-sent event. Reopening a thread whose client state is behind (cold tab, or the
thread atom was torn down after THREAD_STATE_IDLE_TTL_MS = 5 min idle) resumes the subscription
with afterSequence, and the server replays every persisted event since that cursor, one frame
per event, with no cap and no coalescing. The client applies them one at a time, and each apply
publishes a full new thread state that re-runs the entire timeline derivation and re-renders the
streaming message's markdown. Total work is superlinear in the backlog; a few thousand deltas locks
the main thread for minutes.

This is not gated on enableAssistantStreaming. The replay applies to all persisted event
types, and real agentic threads are dominated by thread.activity-appended (tool calls): a sampled
real database (streaming off) has a thread with 4,830 events — 4,170 of them activity-appended
plus 350 message events. The reducer re-sorts the entire activities array on every one of them
(threadReducer.ts:507-513), and every publication re-runs deriveWorkLogEntries over all
activities. Streaming mode just makes the text-delta variant of the same problem catastrophically
worse (one event per provider chunk; buffered mode caps text at one event per ~24KB via
MAX_BUFFERED_ASSISTANT_CHARS).

Measurements

UI-level (real MessagesTimeline + markdown + Shiki, via the /perf.html playground, Firefox
headless, dev build — absolute numbers inflated by dev mode, ratios are the signal):

deltas batch wall time React commits commit time worst stall
500 1 (today) 14.7s 1,140 9.5s 105ms
1000 1 (today) 77s 2,330 47.7s 566ms
2000 1 (today) 212s 4,697 135s 443ms
2000 32 (simulated coalescing) 8.1s 179 5.3s 257ms
350 + 4200 activities (real-thread mix, works with streaming OFF) 1 (today) 36.8s 7,114 11.2s 568ms
350 + 4200 activities 32 5.4s 325 2.4s 98ms

4× the backlog ⇒ ~14× the time (superlinear), and batching applications 32-at-a-time is a 26×
speedup — the cost is dominated by per-event state publication, not by the text itself.

No-UI (unit level, packages/client-runtime/src/state/threadReplayPerf.test.ts):

  • 2000 replayed deltas produce 2,002 state publications through the real resume path
    (threads.ts applyItem → SubscriptionRef.set per event) — the deterministic signature.
  • Pure reducer fold over N deltas: 1000 → ~12ms, 2000 → ~18ms, 4000 → ~40ms, 8000 → ~344ms.

Mechanism

  1. Server replays the raw firehose. subscribeThread with afterSequence does
    readEvents(afterSequence, Number.MAX_SAFE_INTEGER) and emits every event individually
    (apps/server/src/ws.ts:1391-1416). The shell stream already solves both halves of this —
    Stream.groupedWithin(512, 50ms) coalescing (ws.ts:786-800) and a SHELL_RESUME_MAX_GAP
    snapshot fallback (ws.ts:1290-1309) — but the thread-detail stream has neither.
  2. Client applies one event per state publication. Stream.runForEach(applyItem) with no
    batching (packages/client-runtime/src/state/threads.ts:298); every delta does a
    SubscriptionRef.set → atom emission → React render.
  3. Each apply is O(thread size). The delta append re-concatenates the whole accumulated text
    and copies the whole messages array (packages/client-runtime/src/state/threadReducer.ts:246-262);
    thread.activity-appended filters, appends, and re-sorts the entire activities array per
    event
    (threadReducer.ts:507-513).
  4. Each publication re-derives the whole timeline (deriveTimelineEntries full re-sort,
    O(n²) deriveRevertTurnCountByUserMessageId, row rebuild) and re-renders the streaming
    message's markdown; the Shiki highlight cache is deliberately bypassed while
    isStreaming (apps/web/src/components/ChatMarkdown.tsx:672, only stored if (!isStreaming)),
    so replayed deltas re-highlight every code block from scratch each time.

Reproduction (deterministic, no provider/server needed)

  • UI playground: vp run dev, open http://localhost:<web-port>/perf.html?deltas=2000&auto=1
    (streaming-delta variant) or /perf.html?deltas=350&activities=4200&auto=1 (real agentic mix,
    reproduces with streaming off). Params: deltas, activities, batch (raise to preview
    client-side coalescing), history, code.
    Added in feat(web): add thread replay perf playground page.
  • Unit gauge (CI-able): pnpm --filter @t3tools/client-runtime test src/state/threadReplayPerf.test.ts
    — logs reducer timings and the emissions-per-delta count.
  • End-to-end (optional): point providers.cursor.binaryPath at a wrapper around
    apps/server/scripts/acp-mock-agent.ts with T3_ACP_PROMPT_RESPONSE_CHUNK_COUNT=3000 and turn on
    enableAssistantStreaming; run a turn, wait >5 min (or reload), reopen the thread.

A screen recording of the 1000-delta replay is attached (real time and 4× speed).

Suspected fixes (in leverage order)

  1. Server: cap the resume gap like the shell path — beyond a threshold send a fresh snapshot
    instead of replaying (ws.ts:1290-1309 pattern).
  2. Server/client: coalesce replay events (Stream.groupedWithin) and fold a chunk through the
    reducer before a single SubscriptionRef.set (threads.ts:298).
  3. Client: let replayed/catch-up deltas use and populate the Shiki cache (the isStreaming bypass
    is right for live typing, wrong for replay); consider useDeferredValue for streaming markdown.

The batch=32 playground row approximates fix 2 alone: ~26× improvement.

Activity

  1. colonelpanic8 commented on Jul 26, 2026

    @colonelpanic8
    ContributorAuthor
    thread-replay-chug-4x.mp4

    Here is a video replay at 4x showing the issue with a reproduction thing that I built

  2. changed the title [-]Reopening a thread with a streaming-delta backlog freezes the UI for minutes (quadratic replay)[/-] [+]Reopening a thread with a large event backlog freezes the UI while it visibly re-replays (quadratic replay)[/+] on Jul 27, 2026
  3. colonelpanic8 commented on Jul 27, 2026

    @colonelpanic8
    ContributorAuthor

    Update after validating against a real database: this is not streaming-gated. A sampled real state.sqlite (with enableAssistantStreaming off) has a thread carrying 4,830 events — 4,170 of them thread.activity-appended (tool calls). Replaying that exact mix through the playground (/perf.html?deltas=350&activities=4200&auto=1) chugs for ~37s with 7,114 React commits and a 568ms worst stall; batch=32 drops it to ~5.4s / 325 commits. The activity path is also the worst per-event reducer cost: threadReducer.ts:507-513 re-sorts the whole activities array on every event. Title and body updated accordingly; the mechanism and suspected fixes are unchanged.

  4. changed the title [-]Reopening a thread with a large event backlog freezes the UI while it visibly re-replays (quadratic replay)[/-] [+][Bug]: Reopening a thread with a large event backlog freezes the UI while it visibly re-replays (quadratic replay)[/+] on Jul 27, 2026
  5. colonelpanic8 commented on Jul 27, 2026

    @colonelpanic8
    ContributorAuthor
    thread-replay-activities-2x.mp4

    not the best video because it doesnt follow the scroll but you can see how long it takes to play those back with the wall clock time.

  6. chountalas commented on Jul 29, 2026

    @chountalas

    Adding a server-side profile, plus a structural trigger that I think explains why this fires far more often than "reopening a thread" suggests.

    Desktop nightly 0.0.30-nightly.20260728.935, macOS 26.5 on an M2 Max, Codex provider. Assistant text was arriving at roughly 4 tokens/sec. The same model driven from a plain terminal was fast at that same moment, and over the same 8 minute window codex app-server used 10s of CPU while the T3 backend child used 6m41s, pegged at 100% of one core. The cost was on our side.

    sample <backend-pid> 5, main thread, 3,443 samples:

    • 3,027 (88%) inside a single uv__run_check / CheckImmediate callback chain
    • 1,927 of those in node::sqlite::StatementSync::All
    • roughly 1,095 more in the same chain directly above that frame, which lines up with the per row Schema.decodeUnknownEffect plus two fromJsonString fields in OrchestrationEventStore.ts:31-33
    • only 416 samples (12%) anywhere else

    Database at capture: 904 MB, 92 threads, 260,679 thread events. 195,437 were thread.message-sent totaling 60 MB, and 57,181 were thread.activity-appended totaling 248 MB of payload. Three consecutive message-sent payloads:

    {"threadId":"…","messageId":"…","text":"",           "turnId":"…","streaming":…}
    {"threadId":"…","messageId":"…","text":".",          "turnId":"…","streaming":…}
    {"threadId":"…","messageId":"…","text":" throughout","turnId":"…","streaming":…}
    

    One event per streamed delta, averaging 319 bytes to carry a few bytes of text. Daily volume over five days of ordinary use, 24th through 28th of July: 3,504, then 22,575, then 64,655, then 60,811, then 103,082, with the last day still in progress when I captured this.

    Why the catch-up read specifically? There are only three steady state readers of orchestration_events. The projection pipeline sits at the head and reads tiny ranges. Shell replay is capped at SHELL_RESUME_MAX_GAP = 1000 with a snapshot fallback. Thread catch-up is readEvents(afterSequence, Number.MAX_SAFE_INTEGER) with the per thread filter applied after the read. That last one is the only unguarded large-read path on the table.

    The part I did not expect is that it recurs without reopening anything. In packages/client-runtime/src/state/threads.ts, applyItem advances lastSequence only to events arriving on this thread's stream (line 206), and the server stream is already filtered to that thread. So a thread's resume cursor lags the global head by the sum of every other thread's traffic, permanently, and the gap only widens. That same subscription resubscribes on every foreground wake and retries failures every 250ms (lines 293-297). At 100k events/day, full width global rescans recur on ordinary app focus, per cached thread state, with no reopen involved. It also explains two things I could not otherwise account for: restarting the app did not help at all, and a brand new empty thread was just as slow.

    On wording, I do not think "blocks" is right. readFromSequence pages at READ_PAGE_SIZE = 500 and the fiber scheduler yields between effects, so this is saturation rather than a monolithic block. The backend's single JS thread spends its time on synchronous SQLite reads plus per row decode, and everything else on that thread contends with it. NodeSqliteClient.ts is synchronous node:sqlite on the main thread behind a one permit semaphore, and there are no worker threads in apps/server, so that work has nowhere else to go.

    One control worth reporting. I purged only the thread rows and kept every project registered, including one pointed at a very large directory tree. Backend CPU dropped from 100% to 2.4% immediately. That seems to rule out VCS status scanning as the primary cost, at least here.

    This is the same mechanism the commenter on #996 described from a 4.43 GiB database, down to the stale subscribeThread cursors and the filtering after decode. Their prewarmer observation fits too. Opening ten threads at once froze the app completely here, which is what you would expect from ten unbounded catch-ups queued behind one semaphore.

    So, #3510. It already implements this, in its own words replaying at most 1,000 global events after the client cursor and serving a fresh thread snapshot beyond that. It has been open since June. It is large and touches 35 files, but its author notes the first server commit carries the reliability fix alone with no UI changes. From a user's side, landing just that piece would resolve everything I measured. The client already handles a kind:"snapshot" frame mid-stream and resets lastSequence when it does (threads.ts:195-199), so the fallback should not break resuming clients.

    Happy to put up a small server-only PR for the gap check and snapshot fallback if that would help, though I would rather not duplicate #3510 without a maintainer saying it is wanted.


    Workaround, and it costs you every thread you have. Conversations, messages, activities, all gone. Projects, sessions and paired devices survive. Back up first, and only do this if you are genuinely stuck.

    Quit the app fully, copy state.sqlite plus any -wal and -shm files somewhere safe, then delete orchestration_events where aggregate_kind = 'thread', the thread command receipts and the thread projection tables, and VACUUM. Keeping projection_projects, the auth tables, effect_sql_migrations and projection_state is what preserves projects and pairings. Here that took the database from 904 MB to 312 KB, and backend CPU from 100% to about 2%. Migrations do not re-run, and the AUTOINCREMENT counter stays level with the projector cursors, so nothing replays. Schema references are as of this nightly and may drift.

  7. VincentShipsIt commented on Jul 30, 2026

    @VincentShipsIt

    Corroborating desktop profile (macOS 0.0.31) — agentic tool mix, not only stream-replay

    Adding a real local database snapshot that lines up with the “tool activities dominate” update on this issue.

    At capture

    • state.sqlite 587 MB
    • 151,749 orchestration events
    • 127,380 thread.message-sent (streaming was on)
    • 22,813 thread.activity-appended (~174 MB event payload / ~168 MB projected)
    • Active QA thread: 2,461 activities / ~80 MB projected payload alone
    • Another active QA: 981 activities / ~20 MB
    • Soft-deleted threads still retained full event logs (one archived thread had 102k events / 14.7k activities)

    Symptom
    After a few tool rounds on Grok Build in T3, the same turn becomes very slow. New empty threads are snappy. Matches the superlinear client cost story: each activity-appended grows the set that every subsequent apply re-sorts / re-derives.

    Also hit the catch-up side
    Server traces showed heavy subscribeThread + projector/SQL work even when not deliberately reopening cold tabs — consistent with other comments that this isn’t only “reopen after 5 min idle.”

    Recovery
    Purged deleted-thread data + wiped history on fat active threads + disabled enableAssistantStreaming + VACUUM → 288 KB DB. App usable again.

    Related signal for maintainers: #4008 (per-delta shell-summary / full activity rescan on the server path) stacked on top of this client quadratic apply. Fixing only one side still leaves the other.

    I can share a sanitized event-type histogram or a minimal perf.html comparison against this activity mix if that helps prioritize coalescing vs snapshot fallback vs activity paging.

  8. juliusmarminge commented on Sep 15, 2026

    @juliusmarminge
    Member

    Closing as fixed by #8309 (and the batch apply already on main from #11302).

    The quadratic reopen/replay path this issue described is what the client-runtime batching + settled-turn/older-page fixes targeted; GitHub didn’t auto-close because the merged PR body had no Closes keywords.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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