TL;DR: Nothing in the stack sends traffic on an idle Agent WebSocket, so the network drops it after a minute or two of silence, with no close frame. The browser still reports OPEN, partysocket never reconnects, and the next useAgentChat message is lost silently. Cloudflare's own docs prescribe the fix (a client-side ping answered by setWebSocketAutoResponse), and the SDK is the right place for it.
Versions
agents@0.22.0, @cloudflare/think@0.17.0, partysocket@1.3.0
- Same on
main as of 2026-09-10 (b9142be): no ping in AgentClient/useAgent, no setWebSocketAutoResponse anywhere in the repo, acceptWebSocket(ws, tags) called bare in packages/agents/src/lifecycle/durable-object-lifecycle.ts
Reproduce
- Open a
useAgentChat pane against a deployed Agent (ours: a Worker forwarding the upgrade to the Agent's Durable Object with stub.fetch(request), the documented pattern).
- Do nothing for two minutes.
- Send a message.
Result: the message never arrives and nothing reports it. Wrapping window.WebSocket to log open/close/frames shows why:
| idle before send |
result |
| ~0 s |
answered |
| ~30 s |
answered |
| 133 s |
lost |
| 175 s |
lost |
| ~290 s |
lost |
After the drop, readyState is still OPEN, so ws.send() succeeds and the frame goes nowhere. No close event fires, so partysocket's reconnect (which is driven by close events) never runs. useAgentChat sends cf_agent_use_chat_request, gets no cf_agent_use_chat_response, and the AI SDK sits in submitted forever.
Why
Cloudflare's WebSockets docs: "Cloudflare will close a WebSocket connection when no data is transmitted in either direction for a period of time. [...] To keep long-lived connections alive during periods of inactivity, implement a client-side heartbeat (ping/pong) mechanism."
The Workers best-practices page gives the Durable Object half: "Use setWebSocketAutoResponse for ping/pong heartbeats that do not wake the object." Per the pricing page, auto-responses cost nothing.
Neither half exists in the SDK. The only "heartbeat" is the alarm-backed keepAlive(), which keeps the object in memory and never touches the socket.
Most apps do not notice because something else closes or replaces the socket first: a reload, a deploy, or a protocol with its own heartbeat. A chat pane left open until the person has a question is exactly the case that shows it.
Proposal
- Server: the lifecycle registers a
ping → pong auto-response pair in the constructor, by default or as an option, the way cloudflare/actors does it (config.sockets.autoResponse, packages/core/src/index.ts). Think and the base Agent already drop non-protocol text frames silently, so today a ping that reaches onMessage wakes the object and is ignored.
- Client:
AgentClient (and so useAgent) sends ping on a configurable interval while open, and treats a missing pong as a dead socket: close locally so the existing reconnect path runs. The client and useAgentChat already ignore non-JSON frames, so pong needs no handling beyond the liveness check.
Apps can add both around the SDK today (we are), but every Agent socket is exposed to the same rule, and only the SDK can fix it once.
References
TL;DR: Nothing in the stack sends traffic on an idle Agent WebSocket, so the network drops it after a minute or two of silence, with no close frame. The browser still reports
OPEN, partysocket never reconnects, and the nextuseAgentChatmessage is lost silently. Cloudflare's own docs prescribe the fix (a client-side ping answered bysetWebSocketAutoResponse), and the SDK is the right place for it.Versions
agents@0.22.0,@cloudflare/think@0.17.0,partysocket@1.3.0mainas of 2026-09-10 (b9142be): no ping inAgentClient/useAgent, nosetWebSocketAutoResponseanywhere in the repo,acceptWebSocket(ws, tags)called bare inpackages/agents/src/lifecycle/durable-object-lifecycle.tsReproduce
useAgentChatpane against a deployed Agent (ours: a Worker forwarding the upgrade to the Agent's Durable Object withstub.fetch(request), the documented pattern).Result: the message never arrives and nothing reports it. Wrapping
window.WebSocketto log open/close/frames shows why:After the drop,
readyStateis stillOPEN, sows.send()succeeds and the frame goes nowhere. Nocloseevent fires, so partysocket's reconnect (which is driven by close events) never runs.useAgentChatsendscf_agent_use_chat_request, gets nocf_agent_use_chat_response, and the AI SDK sits insubmittedforever.Why
Cloudflare's WebSockets docs: "Cloudflare will close a WebSocket connection when no data is transmitted in either direction for a period of time. [...] To keep long-lived connections alive during periods of inactivity, implement a client-side heartbeat (ping/pong) mechanism."
The Workers best-practices page gives the Durable Object half: "Use
setWebSocketAutoResponsefor ping/pong heartbeats that do not wake the object." Per the pricing page, auto-responses cost nothing.Neither half exists in the SDK. The only "heartbeat" is the alarm-backed
keepAlive(), which keeps the object in memory and never touches the socket.Most apps do not notice because something else closes or replaces the socket first: a reload, a deploy, or a protocol with its own heartbeat. A chat pane left open until the person has a question is exactly the case that shows it.
Proposal
ping→pongauto-response pair in the constructor, by default or as an option, the waycloudflare/actorsdoes it (config.sockets.autoResponse,packages/core/src/index.ts). Think and the baseAgentalready drop non-protocol text frames silently, so today apingthat reachesonMessagewakes the object and is ignored.AgentClient(and souseAgent) sendspingon a configurable interval while open, and treats a missingpongas a dead socket: close locally so the existing reconnect path runs. The client anduseAgentChatalready ignore non-JSON frames, sopongneeds no handling beyond the liveness check.Apps can add both around the SDK today (we are), but every Agent socket is exposed to the same rule, and only the SDK can fix it once.
References
setWebSocketAutoResponse