From 0217bf5ccebf83790fa98dfae9f309a548f8fa0a Mon Sep 17 00:00:00 2001 From: Basit Mustafa Date: Fri, 24 Apr 2026 11:44:47 -0700 Subject: [PATCH] perf(streaming): throttle live render to ~15fps to prevent crash under GC pressure (#966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _scheduleRender() uses requestAnimationFrame to update the live assistant message during streaming. rAF fires at up to 60fps, but each DOM update takes 50-150ms on sessions with long histories — far exceeding the 16ms rAF budget. During GC pauses (which can run for hundreds of milliseconds), rAF callbacks accumulate. When the GC yields, the browser executes all queued callbacks sequentially in a single RunTask. A Chrome performance trace shows a 13.6-second RunTask containing 1,240 accumulated render callbacks — which causes the renderer to crash (Chrome error codes 4/5, ERR_EMPTY_RESPONSE / ERR_CONNECTION_RESET). Fix: track the last render timestamp and delay scheduling the next rAF until at least 66ms (15fps) have elapsed since the previous render. If within the 66ms window, use setTimeout to defer the rAF rather than skipping it — this batches token updates without dropping any content. The 66ms interval is conservative enough to prevent runaway accumulation while fast enough that streaming text still feels immediate. The _renderPending flag continues to prevent double-scheduling within each interval. Co-authored with Claude Sonnet 4.6 / Anthropic. --- static/messages.js | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/static/messages.js b/static/messages.js index 02d2f3a5..ed3ef5c3 100644 --- a/static/messages.js +++ b/static/messages.js @@ -449,13 +449,26 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(!_SMD_SAFE_URL_RE.test(v)){n.removeAttribute('src');n.setAttribute('data-blocked-scheme','1');} } } + let _lastRenderMs=0; function _scheduleRender(){ if(_renderPending) return; if(_streamFinalized) return; // Bug A: don't schedule new rAF after stream finalized _renderPending=true; - _pendingRafHandle=requestAnimationFrame(()=>{ + // Cap render rate to ~15fps. The browser's rAF fires at 60fps, but each DOM + // update takes 50-150ms on large sessions. During GC pauses, rAF callbacks + // accumulate and then execute all at once, blocking the main thread for + // multi-second stretches and crashing the renderer (Chrome error code 4/5). + // Throttling to 66ms intervals prevents this pileup without noticeable + // visual degradation — streaming text updates still feel immediate. + // performance.now() is monotonic so tab suspend/resume and NTP adjustments + // can't produce negative or enormous deltas. + const sinceLastMs=performance.now()-_lastRenderMs; + const _doRender=()=>{ _pendingRafHandle=null; _renderPending=false; + // Guard: a pending setTimeout+rAF can outlive stream finalization. + if(_streamFinalized) return; + _lastRenderMs=performance.now(); const parsed=_parseStreamState(); _renderLiveThinking(parsed); if(assistantBody){ @@ -478,7 +491,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } } scrollIfPinned(); - }); + }; + if(sinceLastMs>=66){ + _pendingRafHandle=requestAnimationFrame(_doRender); + } else { + _pendingRafHandle=setTimeout(()=>requestAnimationFrame(_doRender), 66-sinceLastMs); + } } function _wireSSE(source){