WebSocket support for Stateful Actors is in beta. APIs and limits may change before general availability.
idFromName with the same name routes the new socket back to the same actor, and everything in ctx.storage is exactly where the last connection left it. A client that reconnects with backoff and re-syncs on open loses nothing but the socket.
How a Connection Is Established
Your function’sfetch runs once, at the handshake — check the Upgrade header, authenticate, pick the actor, forward. It never sees another byte of the connection; per-message logic lives in the actor’s webSocket() handler. The full front-door pattern is on the WebSockets page.
What a client can rely on at and after the handshake:
- The upgrade must be a
GET. A non-GETrequest with anUpgradeheader is refused with HTTP400— no handshake starts. - The first client-offered subprotocol is echoed. Offer
["chat.v2", "chat.v1"]and the connection is accepted withchat.v2. The actor cannot select among the offers today; it observes the echoed value asws.protocol. Put your preferred protocol first. - Compression is never negotiated. A
permessage-deflateoffer is accepted without the extension; frames flow uncompressed. - Text and binary frames both work. The handler’s
messagelistener receives(data, isBinary); binary frames round-trip byte-identically. - Protocol-level pings are answered by the platform, payload echoed. You don’t write keepalive code on the actor side.
How Connections End
Today, a connection lives at most about five minutes. The total-duration budget applies even to a socket actively exchanging frames; the cutoff surfaces as close code
1006 with no close frame. This is a current limit, not the contract — it will be raised in a later release. Until then, design clients to ride through a reconnect every few minutes as a matter of course.
There is no separate idle kill below that budget — a socket that goes quiet for 90 seconds and then sends a frame still gets that frame delivered and handled. The duration budget is what ends it.
That guarantee covers the platform’s half of the path only. NATs, corporate proxies, and load balancers between your client and Telnyx run their own idle timers, and any of them can drop a quiet connection long before the duration budget would — often silently, leaving the client holding a socket that looks open but delivers nothing. No close event fires for a drop like that, so reconnect logic alone never notices it; only traffic does. If your clients sit quiet for long stretches, send a periodic heartbeat: non-browser clients can use a protocol-level ping, which the platform answers, and browser WebSocket clients — which cannot send pings — should use a small application-level frame the webSocket() handler replies to. A heartbeat frame is one more frame through the same single-threaded dispatch, so keep it small and infrequent, and give your client library’s own idle or read timeouts the same review.
Deploys sever live sockets. Shipping a new version of your function drops every connection it carries as 1006. This is another reason reconnect logic is not optional: your own deploys exercise it.
Reconnecting
A complete client. It reconnects on every close except the deliberate ones —1000, or an application-defined 4xxx — backs off exponentially, backs off harder on 1013, and re-syncs on open — the socket is new even though the actor and its storage are not.
idFromName("standup") routes to the same actor every time, so the durable sequence counters, history, and alarms of the conversation are all still there. What does not survive is anything held only in the actor’s instance fields or in the old socket itself — the same rule as the rest of the platform: only ctx.storage is real. See Lifecycle & placement.
Limits
The queue limit is a consequence of single-threaded dispatch: an inbound frame is handled like a method call — one at a time per instance, serialized with RPCs and alarms. While one handler runs, further frames queue; a slow handler with fast senders overflows the queue and the socket closes
1013. Keep per-message work well under the 30-second budget — the queue bound is sized for handlers that finish in milliseconds, not tens of seconds.
The same serialization sets the practical ceiling on how many sockets one instance should carry. The table has no socket-count row because the working bound is not a count — it is aggregate frame rate. An instance handles one frame at a time from all of its sockets combined: at one millisecond of handler work per frame, the whole instance drains at most on the order of a thousand inbound frames per second, and a ctx.broadcast() inside a handler writes one frame to every connected socket, so fan-out work grows with the audience. The 256-event queue absorbs a burst of at most a couple hundred frames, not thousands of senders talking at once. When one name’s combined rate approaches what its handlers can drain, partition the workload across names — a room per idFromName, a shard per topic or region — so each instance coordinates only the sockets whose combined traffic it can serialize. An actor is a unit of coordination, not a unit of capacity.
The outbound direction is the mirror image, and it is not policed. ws.send() never blocks the handler, and the platform never drops or coalesces frames on a live connection — anything the client hasn’t drained yet waits in the actor’s memory, and ws.bufferedAmount (standard Node ws) reports how much is queued on a socket. Against a responsive client that number stays near zero; against a stalled one — a backgrounded tab, a dead radio link — every ctx.broadcast() tick adds another payload to the stalled socket’s queue. Bound what one slow consumer can cost: keep broadcast payloads small, and when updates outpace a client, send the latest state rather than queueing every delta — durable state in ctx.storage means a client that fell behind can re-sync from a snapshot instead of replaying a backlog. A socket whose bufferedAmount only grows is gone in every way that matters; close it, and the reconnect-and-re-sync logic every client here needs anyway will pick it back up.
What Holds the Actor in Memory
While a connection is open its actor stays resident — the actor is not deactivated between frames — and under today’s duration budget a connection never lives long enough for the idle eviction described in Lifecycle & placement to be a factor. The cost model follows: a million idle actors are cheap; a million actors each holding an open socket are a million resident activations. Teardown is orderly on the actor side.close and error listeners run under the same single-threaded dispatch as any other event, and a ctx.storage write inside a close handler commits — the instance stays resident until those final handlers finish, not merely until the raw socket drops. Recording “last seen” in a close handler is safe. After the last socket’s close handler finishes, the actor is an ordinary idle actor again: evictable, and revivable by the next idFromName call — or by an alarm it set while the connection was up.
Next Steps
- WebSockets — the front-door pattern, the
webSocket()handler, and how messages dispatch - Lifecycle & placement — eviction and restart for actors generally
- Alarms — deferred work that outlives any connection