Skip to main content
WebSocket support for Stateful Actors is in beta. APIs and limits may change before general availability.
A client opens one WebSocket. Your function runs once, at the handshake — check the upgrade, authenticate, decide which actor owns this connection — and hands off. From then on the actor owns the live socket: each inbound frame is dispatched like a method call — one at a time per instance, serialized with RPCs and alarms — durable state is ordinary ctx.storage, and replies stream straight back with ws.send(). That shape removes the plumbing a chat, agent, or live-session backend usually accretes. When the connection tier and the state tier are separate fleets, every message crosses a pub/sub bridge on the way in and needs a reverse channel to find whichever host holds the socket on the way out. Here the socket and the state live in the same single-threaded instance — a message handler is a plain method body that reads storage, writes storage, and sends. Any actor class can terminate WebSockets — declaring the webSocket() method is the whole opt-in; there is no flag to enable, in telnyx.toml or anywhere else. Actor names used with sockets must be printable ASCII with no leading or trailing whitespace, and any header values your function forwards must be printable ASCII today.

The Two Halves

The boundary is strict: per-message logic never lives in the function. After the handshake the function is done — frames are delivered only to the actor. Everything the function learned rides the forwarded request as headers, so the socket reaches the actor already authenticated. The req the actor sees carries only those application headers; transport and handshake headers (Upgrade, Sec-WebSocket-*) are stripped. The smallest complete pairing:
(authenticate and roomFor stand in for your auth and routing; a complete, runnable version is below.) The actor opts in by defining one optional method:
ws has Node ws socket semantics — ws.on("message", (data, isBinary) => ...), ws.send(data), ws.close(code, reason) — not a browser socket and not Cloudflare’s WebSocketPair/accept(). Text and binary frames both work. An actor with no webSocket() method closes incoming sockets with code 1011.

A Chat Room, End to End

One module exports both halves. The ChatRoom actor holds the room’s messages and its live sockets; the default export is the front door.
Connect with any WebSocket client:
Every socket opened on /rooms/lobby lands on the same ChatRoom instance; /rooms/standup is a different instance with its own seq, its own messages, and its own sockets. ctx.count() and ctx.broadcast() see only this instance’s sockets — a broadcast never crosses actor ids, and a socket never moves between them.

Sending to Some Sockets, Not All

ctx.broadcast() is all-or-nothing: one frame to every socket on the instance. There is no built-in way to address a subset — no socket list to filter, no per-socket tags. “Everyone except the sender” and “just this user” are yours to build, and on a single-threaded instance the whole mechanism is one Map: filled in webSocket(), pruned in the close listener.
The map is a plain instance field, and here — unlike almost everywhere else on the platform — that is correct, with nothing written to ctx.storage. Sockets and instance memory share a lifetime: an open socket keeps the instance resident, and the events that replace an instance — a redeploy, an infrastructure restart — sever its sockets too. A fresh instance therefore starts with no sockets and an empty map, never one without the other; there is nothing to persist and nothing to rebuild. What should outlive the connection — who was here, what they said — goes in ctx.storage as usual.

Messages Are Single-Threaded, Like Everything Else

An inbound frame is dispatched like a method call, under the execution model’s “one call at a time” guarantee: while a message handler runs, frames from other sockets on the same instance, RPC calls, and the alarm all wait. That is exactly what makes the chat room’s read-modify-write on seq safe with any number of concurrent senders. The consequences:
  • The 30-second method budget applies to each message handler. A handler that throws or exceeds it closes the socket with code 1011.
  • ws.send() is immediate — and not held for durability. A method’s return value is held until your storage writes are durable; a sent frame is not. If a handler sends and then fails, the client keeps a frame describing state that never committed. If clients must act only on committed state, make frames re-derivable from storage on reconnect — the chat room’s durable seq makes gaps and replays detectable.
  • Keep per-message work bounded. Frames that arrive while a handler runs queue, up to 256 events or 1 MiB; overflow closes the socket with 1013 (back off before reconnecting), and a single inbound frame over 1 MiB closes it with 1009. Don’t stream minutes of work from one handler — chunk it across messages, or drive it from an alarm. ws.send() itself never blocks the handler.
  • High-frequency senders should coalesce. Every frame costs a full dispatch — one turn of the instance’s single thread, one queue slot — so a client emitting a stream of tiny messages (sensor readings, cursor positions, game state) spends the queue on frame count, not bytes: at 100 messages a second, a single handler that stalls for three seconds is enough to overflow the 256-event queue and close the socket with 1013. Batch logical messages into one frame — an array, flushed on a short timer or a count threshold — and loop over the batch in the handler. Fifty readings in one frame cost one dispatch and one queue slot instead of fifty; just keep the batch under the 1 MiB frame cap.
An open socket also keeps the actor in memory for the duration of the connection.

Connections End; State Doesn’t

Every connection ends. Today a connection lives at most about five minutes from the handshake — even on a socket actively exchanging frames — and the cutoff surfaces as an abnormal close (code 1006, no close frame). This cap will be raised in a future update. Function redeploys and infrastructure restarts sever sockets the same way; a deliberate close from either side is code 1000. Reconnecting is the client’s job: back off, reopen, rejoin the same name. idFromName routes the new socket back to the same actor, and everything in ctx.storage survived — the room’s messages and seq are intact; only the socket is new. A reconnect is a new socket on the actor, never a resumption of the old one. The full close-code table and reconnect guidance are on Connection Lifecycle.

Push Without an Inbound Frame

An actor runs only when something calls it — a frame, an RPC, or an alarm; time passing alone runs nothing. To push to connected clients without waiting for them to send anything, let the alarm make the call and fan out with ctx.broadcast():
The alarm handler is an ordinary method call, so it can read storage, write storage, and send — a message handler on one socket can likewise broadcast to all of them, as the chat room does.

Outbound Requests

Egress from an actor is ordinary Node: the global fetch and the global WebSocket client both work from inside any handler — call a model API and stream its output back over the socket, or dial an upstream WebSocket, exchange frames, and close it. The dispatch rules apply unchanged: outbound work inside a message handler holds the instance’s only thread and runs under the 30-second budget, so ship interim results with ws.send() as they arrive, and chunk anything long across messages or drive it from an alarm.

Next Steps

  • Runtime APIwebSocket(), ctx.count(), ctx.broadcast()
  • Connection Lifecycle — duration budget, close codes, reconnect strategy
  • Execution Model — the four guarantees behind single-threaded dispatch
  • Alarms — the delivery contract behind the push pattern
  • Quick Start — scaffold and deploy your first actor