Skip to content

Agent WebSockets have no heartbeat: an idle socket is dropped without a close frame and the client keeps sending into it #2242

Description

@lswith

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

  1. 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).
  2. Do nothing for two minutes.
  3. 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

  1. Server: the lifecycle registers a pingpong 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.
  2. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions