reconnectToStream sends RESUME_REQUEST before WebSocket is open #1000
Description
Problem
WebSocketChatTransport.reconnectToStream() sends CF_AGENT_STREAM_RESUME_REQUEST after a fixed 100ms setTimeout, but after a page refresh the WebSocket hasn't opened yet at that point. The agent.send() throws, the try/catch swallows it, and after the 3s timeout the method returns null → status stays "ready".
Result: After refreshing during an active stream, the text eventually appears (via onAgentMessage), but useChat status never transitions to "streaming" — no abort button, no loading indicator.
Where
WebSocketChatTransport.reconnectToStream() in packages/ai-chat/src/react.ts:
const requestDelay = setTimeout(() => {
if (!resolved) try {
agent.send(JSON.stringify({ type: MessageType.CF_AGENT_STREAM_RESUME_REQUEST }));
} catch {}
}, 100);The 100ms delay was added to avoid a race with the server's automatic CF_AGENT_STREAM_RESUMING in onConnect, but it doesn't account for WebSocket connection time — which is almost always longer than 100ms on a fresh page load.
Suggested fix
Wait for the WebSocket to be open before sending:
function sendWhenOpen(agent, message) {
if (agent.readyState === WebSocket.OPEN) {
agent.send(message);
} else {
agent.addEventListener("open", () => agent.send(message), { once: true });
}
}Related
Follow-up to the fix in PR #999 (which fixed the ReadableStream return from reconnectToStream). The transport pipeline works correctly once the request actually reaches the server — the only issue is the send timing.
See also #896 (comment): #896 (comment)
Activity
alexanderjacobsen commentedon Feb 26, 2026
Closing — my analysis was wrong. PartySocket queues messages when the WebSocket isn't open yet, so the RESUME_REQUEST does get delivered. The root cause of the status issue is something else. Sorry for the noise.
alexanderjacobsen commentedon Feb 26, 2026
Reopening — confirmed with debug logging. The original theory was correct.
On page refresh,
reconnectToStreamis called while the WebSocketreadyStateis3(CLOSED). PartySocket queues the outgoingRESUME_REQUEST, but theonDetectevent listener is registered on a not-yet-connected socket. By the time PartySocket reconnects and the server responds withSTREAM_RESUMING, the 3s timeout has already fired and removed theonDetectlistener. Nobody receives the response.Console output:
PartySocket queues outgoing
send()calls, but incomingaddEventListener("message", ...)listeners registered before the socket opens do NOT receive messages that arrive after reconnection — the listener is on the old internal WebSocket instance.The fix should either wait for the WebSocket to be open before starting the detection flow, or ensure the listener survives reconnection.
threepointone commentedon Feb 26, 2026
good catch. Ill update the PR
threepointone commentedon Feb 26, 2026
btw thanks so much for the detailed reports and your patience. it's a tricky thing to solve, and it's great having info like this to work on.
threepointone commentedon Feb 26, 2026
ok try this one again
npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/ai-chat@999alexanderjacobsen commentedon Feb 27, 2026
The fix looks good in the source but the pkg.pr.new tarball still has the old built output. Did the preview build not re-trigger on the force push?
threepointone commentedon Feb 27, 2026
hmm probably not, they've been having issues
I'll generate a fresh build in a bit, afk
threepointone commentedon Feb 27, 2026
this build should point at the latest hash
npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/ai-chat@81acb00alexanderjacobsen commentedon Feb 28, 2026
@threepointone Tested the new build (
@81acb00). The transport listener never receivesSTREAM_RESUMING—onAgentMessagehandles it first via the fallback path, which doesn't set status tostreaming.Debug logs after refreshing while the model is thinking:
Two issues:
reconnectToStreamis called twice (React strict mode double-effect), both with readyState 3localRequestIdsRefis empty whenSTREAM_RESUMINGarrives, soonAgentMessagedoesn't skip it. The transport'saddEventListener("message", onMessage)never fires.threepointone commentedon Feb 28, 2026
Thanks for reporting again. Could you trynpm i https://pkg.pr.new/@cloudflare/ai-chat@5ad1325?threepointone commentedon Feb 28, 2026
Actually, try this one
npm i https://pkg.pr.new/@cloudflare/ai-chat@b466803alexanderjacobsen commentedon Feb 28, 2026
@threepointone Tested
@b466803. ThehandleStreamResuming/_resumeResolverapproach is correct, but there's a transport instance mismatch that prevents it from working.The problem:
useAgentChatcreatescustomTransportviauseMemo, but theuseChatChatStore (backed byuseRef) captures the transport at construction time. In React Strict Mode (dev),useMemoproduces new instances across re-renders, so the ChatStore holds transport #1 while theonAgentMessageeffect closure captures transport #3.reconnectToStreamsets_resumeResolveron #1, buthandleStreamResumingchecks #3 — which never had a resolver.Debug logs after refresh-during-stream:
The fallback path in
onAgentMessagedoes resume the stream (messages come through), but it bypassesuseChat'smakeRequestflow entirely, sostatusstays"ready"instead of"streaming".Fix: The transport needs to be a stable reference. Either:
useRefinstead ofuseMemoforcustomTransportso the same instance is sharedonAgentMessageaccess the transport through the ChatStore ref instead of closing over itthreepointone commentedon Feb 28, 2026
Here's another try
npm i https://pkg.pr.new/@cloudflare/ai-chat@60c58bf2 remaining items
alexanderjacobsen commentedon Feb 28, 2026
Ignore my Strict Mode comments — that wasn't it. Tested in production mode with logging. After refreshing during streaming:
_pkchanges 3 times during reconnection, recreating the transport each time. The resolver is always on a previous transport thatonAgentMessageno longer references. Is this expected for_pk?alexanderjacobsen commentedon Feb 28, 2026
I think the fix is:
_pkchanges — create once withuseRef, updatethis.agentin-place each rendercustomTransportRef.currentinonAgentMessageinstead of the closure-capturedcustomTransport(and remove it from the effect deps)STREAM_RESUME_REQUEST(readyState was 3 whenreconnectToStreamfired)With (1) and (2) applied locally, the resolver is found and the stream handshake completes.
threepointone commentedon Feb 28, 2026
what user facing bug are you seeing?
alexanderjacobsen commentedon Feb 28, 2026
When refreshing the page while the model is thinking/streaming, the "Thinking..." status doesn't persist. The status goes back to "ready" so the user can send a new message even though the model is still working. The response does eventually come through via the fallback path, but
useChat's status is wrong.threepointone commentedon Feb 28, 2026
I believe our PR fixes that exact scenario, does it not for you?
threepointone commentedon Feb 28, 2026
Like, it's fine if it shows ready while it connects, there's no real way to solve that until the websocket connects (unless you're storing some client side state)
alexanderjacobsen commentedon Feb 28, 2026
The issue is that it stays "ready" permanently, even after the WebSocket reconnects and the server sends STREAM_RESUMING. The transport that receives the STREAM_RESUMING message is a different instance than the one holding the resolver (because
_pkchanged and a new transport was created), so the resolver is never called and the status never transitions to "streaming".threepointone commentedon Feb 28, 2026
can you make a repro? I need to see if you're using an async query or something. you can use the agent starter if that helps https://github.com/cloudflare/agents-starter
threepointone commentedon Feb 28, 2026
I'm putting up a fix for async queries, but I'd still love a repro if possible
threepointone commentedon Feb 28, 2026
try
npm i https://pkg.pr.new/@cloudflare/ai-chat@807365fI bet it's all solved nowalexanderjacobsen commentedon Feb 28, 2026
@807365fdidn't fix it — chat history disappears after refresh now (initialMessages comes back empty). Will put together a minimal repro when I have time.Minimal repro for cloudflare/agents#1000
alexanderjacobsen commentedon Feb 28, 2026
Hey @threepointone — turns out this was on my end. I had set
getInitialMessages: nullin myuseAgentChatcall and was fetching messages manually as a workaround a while back, and forgot about it. That bypassed the SDK's Suspense-based message loading, which is what enables stream resumption.Once I removed that and let the SDK use its default
getInitialMessages, resume works as expected. Thanks for looking into it and for the singleton transport fix!threepointone commentedon Feb 28, 2026
Fantastic glad we got here! I'll merge the pr now. Thanks for digging deep with me!