Skip to content

Commit 7139114

Browse files
authored
🤖 perf: debounce stream delta events to reduce markdown streaming lag (#988)
## Problem High-frequency streaming events (`stream-delta`, `tool-call-delta`, `reasoning-delta`) were triggering immediate state bumps and React re-renders on every token. This caused noticeable lag during fast streaming of markdown content. ## Solution Add a debounce mechanism (~16ms / 60fps cap) for delta event UI notifications: - **Data integrity preserved**: The aggregator is updated immediately on each event - no data is ever lost - **UI updates batched**: `states.bump()` calls are debounced so React only re-renders at most ~60 times per second - **Stream boundaries handled**: `stream-end` and `stream-abort` flush any pending debounced bumps to ensure final state is visible immediately - **Cleanup on removal**: Debounce timers are cleared when workspaces are removed to prevent stale callbacks - **No change for other events**: Tool call start/end, reasoning end, etc. still bump immediately since they're lower frequency ## Risk Assessment | Risk | Assessment | |------|------------| | Data loss | None - aggregator updated immediately | | Timer leak | Mitigated - cleaned up on workspace removal | | Final state hidden | None - flushed on stream-end/abort | | Perceived delay | None - 16ms is sub-perceptual | _Generated with `mux`_
1 parent 72ff655 commit 7139114

File tree

1 file changed

+53
-3
lines changed

1 file changed

+53
-3
lines changed

‎src/browser/stores/WorkspaceStore.ts‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ export class WorkspaceStore {
125125
private workspaceMetadata = new Map<string, FrontendWorkspaceMetadata>(); // Store metadata for name lookup
126126
private queuedMessages = new Map<string, QueuedMessage | null>(); // Cached queued messages
127127

128+
// Debounce timers for high-frequency delta events to reduce re-renders during streaming
129+
// Data is always updated immediately in the aggregator; only UI notification is debounced
130+
private deltaDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
131+
private static readonly DELTA_DEBOUNCE_MS = 16; // ~60fps cap for smooth streaming
132+
128133
/**
129134
* Map of event types to their handlers. This is the single source of truth for:
130135
* 1. Which events should be buffered during replay (the keys)
@@ -154,7 +159,7 @@ export class WorkspaceStore {
154159
},
155160
"stream-delta": (workspaceId, aggregator, data) => {
156161
aggregator.handleStreamDelta(data as never);
157-
this.states.bump(workspaceId);
162+
this.debouncedStateBump(workspaceId);
158163
},
159164
"stream-end": (workspaceId, aggregator, data) => {
160165
const streamEndData = data as StreamEndEvent;
@@ -167,6 +172,8 @@ export class WorkspaceStore {
167172
// Reset retry state on successful stream completion
168173
updatePersistedState(getRetryStateKey(workspaceId), createFreshRetryState());
169174

175+
// Flush any pending debounced bump before final bump to avoid double-bump
176+
this.flushPendingDebouncedBump(workspaceId);
170177
this.states.bump(workspaceId);
171178
this.checkAndBumpRecencyIfChanged();
172179
this.finalizeUsageStats(workspaceId, streamEndData.metadata);
@@ -191,6 +198,8 @@ export class WorkspaceStore {
191198
);
192199
}
193200

201+
// Flush any pending debounced bump before final bump to avoid double-bump
202+
this.flushPendingDebouncedBump(workspaceId);
194203
this.states.bump(workspaceId);
195204
this.dispatchResumeCheck(workspaceId);
196205
this.finalizeUsageStats(workspaceId, streamAbortData.metadata);
@@ -201,7 +210,7 @@ export class WorkspaceStore {
201210
},
202211
"tool-call-delta": (workspaceId, aggregator, data) => {
203212
aggregator.handleToolCallDelta(data as never);
204-
this.states.bump(workspaceId);
213+
this.debouncedStateBump(workspaceId);
205214
},
206215
"tool-call-end": (workspaceId, aggregator, data) => {
207216
aggregator.handleToolCallEnd(data as never);
@@ -210,7 +219,7 @@ export class WorkspaceStore {
210219
},
211220
"reasoning-delta": (workspaceId, aggregator, data) => {
212221
aggregator.handleReasoningDelta(data as never);
213-
this.states.bump(workspaceId);
222+
this.debouncedStateBump(workspaceId);
214223
},
215224
"reasoning-end": (workspaceId, aggregator, data) => {
216225
aggregator.handleReasoningEnd(data as never);
@@ -304,6 +313,40 @@ export class WorkspaceStore {
304313
window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.RESUME_CHECK_REQUESTED, { workspaceId }));
305314
}
306315

316+
/**
317+
* Debounced state bump for high-frequency delta events.
318+
* Coalesces rapid updates (stream-delta, tool-call-delta, reasoning-delta)
319+
* into a single bump per frame (~60fps), reducing React re-renders during streaming.
320+
*
321+
* Data is always updated immediately in the aggregator - only UI notification is debounced.
322+
*/
323+
private debouncedStateBump(workspaceId: string): void {
324+
// Skip if already scheduled
325+
if (this.deltaDebounceTimers.has(workspaceId)) {
326+
return;
327+
}
328+
329+
const timer = setTimeout(() => {
330+
this.deltaDebounceTimers.delete(workspaceId);
331+
this.states.bump(workspaceId);
332+
}, WorkspaceStore.DELTA_DEBOUNCE_MS);
333+
334+
this.deltaDebounceTimers.set(workspaceId, timer);
335+
}
336+
337+
/**
338+
* Flush any pending debounced state bump for a workspace (without double-bumping).
339+
* Used when immediate state visibility is needed (e.g., stream-end).
340+
* Just clears the timer - the caller will bump() immediately after.
341+
*/
342+
private flushPendingDebouncedBump(workspaceId: string): void {
343+
const timer = this.deltaDebounceTimers.get(workspaceId);
344+
if (timer) {
345+
clearTimeout(timer);
346+
this.deltaDebounceTimers.delete(workspaceId);
347+
}
348+
}
349+
307350
/**
308351
* Track stream completion telemetry
309352
*/
@@ -744,6 +787,13 @@ export class WorkspaceStore {
744787
// Clean up consumer manager state
745788
this.consumerManager.removeWorkspace(workspaceId);
746789

790+
// Clean up debounce timer to prevent stale callbacks
791+
const timer = this.deltaDebounceTimers.get(workspaceId);
792+
if (timer) {
793+
clearTimeout(timer);
794+
this.deltaDebounceTimers.delete(workspaceId);
795+
}
796+
747797
// Unsubscribe from IPC
748798
const unsubscribe = this.ipcUnsubscribers.get(workspaceId);
749799
if (unsubscribe) {

0 commit comments

Comments
 (0)