WebSocket support for Stateful Actors is in beta. APIs and limits may change before general availability.
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. TheChatRoom actor holds the room’s messages and its
live sockets; the default export is the front door.
/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.
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 onseq 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 durableseqmakes 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 with1009. 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.
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 (code1006, 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 withctx.broadcast():
Outbound Requests
Egress from an actor is ordinary Node: the globalfetch 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 API —
webSocket(),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