Repository navigation
Description
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.tsapplyItem→SubscriptionRef.setper event) — the deterministic signature. - Pure reducer fold over N deltas: 1000 → ~12ms, 2000 → ~18ms, 4000 → ~40ms, 8000 → ~344ms.
Mechanism
- Server replays the raw firehose.
subscribeThreadwithafterSequencedoes
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 aSHELL_RESUME_MAX_GAP
snapshot fallback (ws.ts:1290-1309) — but the thread-detail stream has neither. - 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. - 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-appendedfilters, appends, and re-sorts the entire activities array per
event (threadReducer.ts:507-513). - Each publication re-derives the whole timeline (
deriveTimelineEntriesfull 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 storedif (!isStreaming)),
so replayed deltas re-highlight every code block from scratch each time.
Reproduction (deterministic, no provider/server needed)
- UI playground:
vp run dev, openhttp://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 infeat(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.binaryPathat a wrapper around
apps/server/scripts/acp-mock-agent.tswithT3_ACP_PROMPT_RESPONSE_CHUNK_COUNT=3000and 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)
- Server: cap the resume gap like the shell path — beyond a threshold send a fresh snapshot
instead of replaying (ws.ts:1290-1309pattern). - Server/client: coalesce replay events (
Stream.groupedWithin) and fold a chunk through the
reducer before a singleSubscriptionRef.set(threads.ts:298). - Client: let replayed/catch-up deltas use and populate the Shiki cache (the
isStreamingbypass
is right for live typing, wrong for replay); consideruseDeferredValuefor streaming markdown.
The batch=32 playground row approximates fix 2 alone: ~26× improvement.
Activity
colonelpanic8 commentedon Jul 26, 2026
thread-replay-chug-4x.mp4
Here is a video replay at 4x showing the issue with a reproduction thing that I built
[-]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)[/+]colonelpanic8 commentedon Jul 27, 2026
Update after validating against a real database: this is not streaming-gated. A sampled real
state.sqlite(withenableAssistantStreamingoff) has a thread carrying 4,830 events — 4,170 of themthread.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=32drops it to ~5.4s / 325 commits. The activity path is also the worst per-event reducer cost:threadReducer.ts:507-513re-sorts the whole activities array on every event. Title and body updated accordingly; the mechanism and suspected fixes are unchanged.[-]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)[/+]colonelpanic8 commentedon Jul 27, 2026
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.
fix(client-runtime): batch thread stream application to keep resume r…
fix(server): cap thread resume replay gap with a snapshot fallback
fix(client-runtime): batch thread stream application to keep resume r…
fix(server): cap thread resume replay gap with a snapshot fallback
chountalas commentedon Jul 29, 2026
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 windowcodex app-serverused 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:uv__run_check/CheckImmediatecallback chainnode::sqlite::StatementSync::AllSchema.decodeUnknownEffectplus twofromJsonStringfields inOrchestrationEventStore.ts:31-33Database at capture: 904 MB, 92 threads, 260,679
threadevents. 195,437 werethread.message-senttotaling 60 MB, and 57,181 werethread.activity-appendedtotaling 248 MB of payload. Three consecutivemessage-sentpayloads: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 atSHELL_RESUME_MAX_GAP = 1000with a snapshot fallback. Thread catch-up isreadEvents(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,applyItemadvanceslastSequenceonly 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.
readFromSequencepages atREAD_PAGE_SIZE = 500and 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.tsis synchronousnode:sqliteon the main thread behind a one permit semaphore, and there are no worker threads inapps/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
subscribeThreadcursors 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 resetslastSequencewhen 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.sqliteplus any-waland-shmfiles somewhere safe, then deleteorchestration_eventswhereaggregate_kind = 'thread', the thread command receipts and the thread projection tables, andVACUUM. Keepingprojection_projects, the auth tables,effect_sql_migrationsandprojection_stateis 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.VincentShipsIt commentedon Jul 30, 2026
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.sqlite587 MBthread.message-sent(streaming was on)thread.activity-appended(~174 MB event payload / ~168 MB projected)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-appendedgrows 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.htmlcomparison against this activity mix if that helps prioritize coalescing vs snapshot fallback vs activity paging.juliusmarminge commentedon Sep 15, 2026
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.