Telnyx Compute: Stateful Actors (Beta) — Full Documentation
Complete page content for Stateful Actors (Beta) (Compute section) of the Telnyx developer docs (https://developers.telnyx.com). Root index: https://developers.telnyx.com/llms.txt · Lightweight index for this subsection: https://developers.telnyx.com/development/llms/compute-stateful-actors-beta-llms-txt.md
Overview
Stateful Actors
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors.mdA stateful actor is a single-threaded server that owns the state of one entity — one user, one cart, one call leg, one chat room. You write it as a TypeScript class; the platform runs one instance per name, routes every call for that name to that instance, runs your methods one at a time, and persists what you write. You never bind a socket, take a lock, or shard it.
debit is a read-modify-write with no lock and no transaction. In a normal request
handler that’s a race; here it isn’t — and that property is the whole product.
How it works derives it from
first principles, starting from a plain C server.
Two words used precisely throughout these docs: the actor is the class you ship
(Account); an instance is its per-name materialization (idFromName("acct_123")
reaches the acct_123 instance of Account).
What the Runtime Guarantees
- One instance per name —
idFromName("acct_123")routes every call to a single owner; no two hosts run it at once. - One call at a time — single-threaded dispatch. No locks, no races inside an instance.
- Durable before reply — a method’s result isn’t returned until the writes it made are persisted.
- Memory is a cache — only what you write to
storagesurvives eviction or restart.
Related Resources
- Bindings — how bindings resolve on
env - KV — globally distributed key-value storage
- Execution Model — function lifecycle and concurrency
Get Started
Stateful Actors Quick Start
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/quick-start.mdShip an
Account actor end-to-end: scaffold, write the class, deploy, and curl it. Each account name is its own actor instance — alice and bob are isolated, each with their own balance, all persisted. Roughly 10 minutes.
What You’ll Build
A function with routes that all share a single durableAccount actor type — but each account name is its own isolated instance:
debit never overdraws, and a committed debit is never lost.
Why an Account
AnAccount exercises the three properties a Stateful Actor gives you:
- Per-instance isolation —
aliceandbobare separate actor instances with their own balance. - Single-threaded dispatch — concurrent debits on the same account serialize automatically. No lock, no race: two debits can’t both pass the balance check.
- Persistence — the balance survives across requests; it’s durable and reloaded when the actor is next invoked.
Prerequisites
- The
telnyx-edgeCLI, installed and authenticated — a release with Stateful Actor deploy support. v0.2.2 and earlier cannot ship an actor project (new-func --actorappears to work, butshipfails); upgrade from the releases page. - A Telnyx API key.
- Node.js (for
npm).
1. Authenticate
2. Scaffold the Actor Project
telnyx.toml (with a [[actors]] block), a sample actor class, a fetch handler, and registers the function (writes a func_id into telnyx.toml). npm install pulls in @telnyx/edge-runtime.
3. Write the Account Actor
Replace the scaffolded actor class withsrc/account.ts. It holds one value — the balance — in this.ctx.storage:
4. Write the Fetch Handler
Replacesrc/index.ts with a handler that routes HTTP to actor method calls. It re-exports the actor class (so it ships with the bundle) and calls it through the ACCOUNT binding on env:
5. Update telnyx.toml
Point the [[actors]] binding at your class. The scaffold generated a sample binding; change it to ACCOUNT → Account:
6. Deploy
ship bundles the project, uploads it, and monitors the deploy — wait for
✅ Func 'account' is now deployed! (in telnyx-edge list, the function’s status
becomes deploy_ok).
7. Hit the URL
ship prints a URL like account-.telnyxcompute.com. The function may take a moment to come up — poll the health endpoint until it returns 200:
aliceends at 70 (100 − 30; the 1000 debit was rejected)bobis independent at 5aliceis unchanged bybob’s activity (isolation), and survives across requests (persistence)
Next Steps
- Shared Actors — add a second function that reads/writes the same
Accountthrough a different binding - Runtime API —
StatefulActor,ctx,storage, alarms - Alarms — schedule deferred work from inside an actor method
Concepts
How It Works
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/concepts/how-it-works.mdTake a concrete problem: keep a balance per account, reachable over HTTP, correct when requests overlap, and intact across restarts. A
debit must never overdraw,
and a committed debit must never be lost. Below is what that actually requires, from
first principles, and how a stateful actor hands you the same thing.
How You’d Build This, from First Principles
Strip it to the metal. One process serves every account —a, b, c, x, y,
… — holding them all in an in-memory map and handling a request for whichever one comes
in. Two things make each debit correct: a per-account lock, so two debits on the
same account can’t interleave (while debits on different accounts still run in
parallel), and a write-ahead log + fsync, so a committed debit is on disk before
you reply.
fsync — you don’t acknowledge the debit until the
bytes are on disk:
fsync, unlock:
fsync
before you reply is durability. Everything else follows:
- one process owns it all — the map, the per-account locks, and the log are shared by
every account
a…y; - durability is the log: a debit is committed once its record is
fsync’d, so a crash loses only what was never acked; - recovery is not replaying an ever-growing log from zero — that gets slower forever. You periodically write a snapshot of all balances and keep only the log records since it; on restart you load the snapshot and replay that short tail. (Postgres’ checkpoint + WAL, SQLite’s WAL mode, and Redis’ RDB + AOF all do exactly this.) The on-disk snapshot + log is the truth; memory is a cache;
- to grow past one process you shard accounts across processes and machines,
routing each account to the one that owns it — and this is where most people stop
hand-rolling and reach for a database,
which is exactly this machine (sharded, replicated, snapshot + write-ahead log) run by
someone else. Postgres’
COMMITis whatpersist_datadoes, its checkpoint is your snapshot, andSELECT … FOR UPDATEis the lock.
The Same Thing as a Stateful Actor
debit(acct_id, …) did the lookup
itself (lookup_or_create) and the logic. With an actor that lookup moves to the
caller: a normal function parses the request, names the account with idFromName,
and calls the method — which now takes only amount and never has to find its own state.
idFromName(acct) is the actor-world lookup_or_create — instead of finding a struct in
this process’s map, it routes to the one instance that owns that account, anywhere (the
“Reaching one account” row below).
Same check-and-decrement, same durability requirement — but no pthread_mutex, no
fsync, no log, no snapshots, no shard map. The two primitives are still there; the
runtime provides them. Here’s the mapping, mechanism by mechanism:
The runtime takes the one process that multiplexed
a, b, c, x, y and splits
it: each account id becomes its own instance. idFromName("a") and idFromName("b")
are two different instances, on two different threads, each with its own storage —
debits on a serialize, debits on a and b run in parallel, and there is no shared
map, per-account lock, or single log to own. A stateful actor is that machine — a
single-threaded server with durable, flush-before-ack storage, one per account — that
the platform shards, routes, and restarts for you. You write the body of debit; the
runtime is the lock, the durability, the recovery, and the router.
The four guarantees the runtime makes from this mapping are stated precisely in
Execution model.
Next Steps
- Execution model — the four guarantees, stated precisely
- Lifecycle & placement — where an instance runs, and what survives a restart
- Addressing — naming and routing instances
- Project structure — the two-export shape
Execution Model
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/concepts/execution-model.mdThe runtime makes four guarantees. Each is the managed form of a mechanism you’d otherwise build by hand — the C server and mapping table in How it works show where each one comes from. Stated precisely: 1. Exactly one instance per name.
idFromName("acct_123") routes every call for that name to one owner — the shard map
and router you’d otherwise build by hand, done for you. No two hosts run account
acct_123 at once, so the instance is the source of truth for that account.
2. One call at a time — no concurrency inside an instance.
This is the per-account pthread_mutex, except you don’t write it and can’t forget it.
One thread per instance runs each method to completion before the next starts. await
yields the thread, but the next call still won’t start until your method returns. The C
debit is correct because it holds the lock across the change; the actor debit is
correct because two debits on one account never run at the same time.
3. Writes are durable before the caller sees a result.
This is write() + fsync() before you reply. When your method returns, the runtime
holds the response until every write you issued has been flushed — the caller can’t
observe a result built on an unflushed write. (If a write rejects after the method
returns, the call fails with ActorOutputGateError instead of returning success.)
4. Only persisted state survives; memory is a cache.
This is “the on-disk snapshot + log is the truth; the in-memory map is a cache.” An
instance can be evicted or restarted between any two calls — a deploy, idle eviction,
host failure — and instance fields (this.x) vanish while ctx.storage does not. The
runtime does the snapshotting and recovery; you just read from storage. Treat memory
exactly like a process that can be SIGKILLed at any instant: only what you flushed to
storage is real.
Next Steps
- How it works — the C-vs-actor derivation behind these guarantees
- Lifecycle & placement — how eviction and restart play out (guarantee 4)
- When to use — the throughput limits these guarantees imply
Lifecycle & Placement
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/concepts/lifecycle.mdA stateful actor instance is activated on demand, kept in memory while it’s busy, evicted when idle, and can be restarted or relocated at any time. You don’t manage any of this — but you design around it, because only what you write to
storage survives.
Where it Runs
An instance is placed on first touch — the first method call to a name brings it up. After that you address it by name, never by location; the platform routes every call to wherever the instance currently lives. (idFromName accepts a locationHint
option, reserved for regional placement — see
Actor Namespace.)
Activation and Eviction
- Activated on demand. Naming an instance is cheap; the first method call to a name brings the instance up and routes the call to it.
- Kept warm while busy, evicted when idle. Between calls, an idle instance may be removed from memory to free resources, then re-activated on the next call.
- Restarted on deploy or host failure. A new code version or a host issue tears the instance down and re-creates it.
What Survives a Restart
In-memory state — instance fields likethis.x — is a cache. It vanishes on
eviction, restart, or relocation. Your actor’s storage is the truth: it is durable
and reloaded when the instance next activates. Treat memory exactly like a process that
can be SIGKILLed at any instant — only what you flushed to storage is real. (This
is guarantee 4 in Execution model.)
A common pattern is to load from storage once on activation and cache the result in
memory; just never assume the cache is still there.
Next Steps
- How it works — the execution model and its guarantees
- Addressing — naming and routing instances
- Runtime API —
ctx.storage,blockConcurrencyWhile
Addressing
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/concepts/addressing.md
env. is your handle to an actor class. You name an instance, get a stub,
and call methods on it.
Two Ways to Name an Instance
idFromName(name)is deterministic: the samenamealways lands on the same instance, from anywhere. This is the common case — pick a stable identity for the entity and let the platform route to its state.newUniqueId()mints a fresh, unguessable instance; you store the id (stub.id) yourself. It currently fails at call time on the platform (a known runtime issue — see Actor Namespace); until it’s fixed, mint an identity yourself and pass it toidFromName.
Choosing a Name
The name is your shard key — choose the entity’s natural identity: a caller’s E.164 number, a user or account id, a room id, or a composite key (tenant:room). Same name →
same single-threaded instance → coherent state for that entity. Spreading load means
choosing names that spread across entities; a single hot name is one single-threaded
bottleneck (see When to use).
Calling the Stub
Both calls return a stub. Calling a method on the stub is an RPC to that one instance: the call is queued there, your method runs, the value comes back.Next Steps
- How it works — why one-instance-per-name gives coherent state
- Project structure — declaring the binding in
telnyx.toml - Runtime API —
ActorNamespaceandActorStub
Reference
Overview
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference.mdThe types in this reference are exported from
@telnyx/edge-runtime (TypeScript).
The Stateful Actor surface at a glance — follow a link for the full page:
Related Resources
- Bindings — how bindings resolve on
env - KV — globally distributed key-value storage
- Execution Model — function lifecycle and concurrency
Base Class
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/base.md
StatefulActor is the base class you extend. Subclass it, declare async methods, and they become RPC-callable from a stub. It holds this.ctx (see Actor Context) and this.env (your bindings).
Construction
You don’t construct actors yourself — the runtime does. The base constructor wiresthis.ctx and this.env, so a subclass that doesn’t need init logic can omit its constructor entirely.
ctx.blockConcurrencyWhile — see Actor Context.
Dispatch Rules
- Public methods are RPC-callable from a stub — declare them
async(every stub call is aPromiseon the caller’s side regardless). - Methods whose names start with
_are internal helpers and are not RPC-exposed — the runtime rejects the call. - The
_prefix is the only opt-out. TypeScript’sprivatekeyword is erased at runtime and does not stop remote calls; name internal helpers with a leading_. fetch(req),alarm(info),webSocket(ws, req), the constructor, and methods defined onStatefulActoritself (not your subclass) are special and not auto-RPC.- Methods have a wall-clock budget (30s by default). A call that exceeds it fails with
ActorMethodTimeoutError— see Errors.
alarm(alarmInfo)
Optional alarm handler. Override to handle scheduled work. The base implementation is a no-op.
fetch(req)
Optional HTTP-style entry. The base default returns 404. Override if you want callers to reach the actor over a raw Request instead of RPC methods:
env.BINDING.idFromName(name).fetch(req).
webSocket(ws, req)
Optional WebSocket entry. Unlike alarm() and fetch(), there is no default implementation — declaring the method is the opt-in. An actor that doesn’t declare webSocket() has no socket behavior at all, and an upgrade routed at it is closed with code 1011 (reason actor has no webSocket handler).
- Called once per accepted connection, with the live socket and the handshake
Request.reqis the request your function’s front door forwarded: the URL plus application headers, with transport and handshake headers stripped — see WebSockets for the contract. Client-sent app headers pass through too, so trust only headers your front door explicitly set or overwrote — the front door must overwrite or strip anything auth-bearing (likex-userabove) before forwarding. wsis a Nodewssocket (import type { WebSocket } from "ws"):ws.on("message", (data, isBinary) => ...),ws.send(data),ws.close(code, reason). There is noWebSocketPair/accept()shape.- Register your listeners before
webSocket()returns. Frames that arrive beforewebSocket()returns — including any the client sent immediately after the handshake — are buffered and replayed in order once it returns; nothing drops. - Socket events share the instance’s single-threaded dispatch.
message,close, anderrorhandlers run one at a time, serialized with RPC methods and alarms, each under the method wall-clock budget (30s by default). A message handler that throws or exceeds the budget closes the socket with code1011.
fetch front door: check the Upgrade header, authenticate, then forward with env.BINDING.idFromName(name).fetch(...). See WebSockets for the front door, connection lifetime, close codes, and delivery semantics.
Related
- WebSockets — the full connection contract behind
webSocket() - Actor Context —
this.ctx: identity, storage, init - Actor Storage —
this.ctx.storage - Configuration — the
telnyx.tomlactor block
Actor Context
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/context.md
this.ctx (an ActorContext) holds the instance’s identity, per-key storage, single alarm, one-shot init, and the instance’s live WebSocket connections.
ctx.id
The customer-supplied name for this actor — opaque string. You chose it via env..idFromName(name). Common patterns: caller E.164, CRM user id, email, or a composite key.
ctx.blockConcurrencyWhile(fn)
One-shot init primitive. Use it inside a subclass constructor to gate all other calls until init finishes. 30s budget — a callback that exceeds it fails init (BlockConcurrencyTimeoutError), and the activation is torn down and retried on the next call.
ctx.setAlarm(when)
Top-level alias for ctx.storage.setAlarm(when). when is ms since epoch. See Alarms.
ctx.count()
The number of WebSocket connections open on this instance right now. Synchronous — no await. Scoped to the instance: sockets on other names of the same actor class are never counted, and a socket never carries over between names.
Callable from any handler — a socket message handler, an RPC method, or the alarm handler.
ctx.broadcast(data)
Send one frame to every WebSocket open on this instance; returns the number of sockets the frame was sent to. A string is sent as a text frame; an ArrayBuffer or ArrayBufferView as a binary frame. Scoped to the instance, like count() — a broadcast never reaches another name’s sockets. Like ws.send(), the write happens immediately; it is not held until the turn’s storage writes commit.
Callable from any handler. Calling it from alarm() is the server-initiated push pattern — the actor sends with no inbound frame prompting it:
Related
- Actor Storage —
ctx.storage - Base Class — where
this.ctxcomes from - Alarms — the alarm contract
- WebSockets — the connection contract behind
count()andbroadcast()
Actor Storage
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/storage.md
this.ctx.storage (an ActorStorage) is the actor’s per-key persistent storage. A turn that returns successfully is persisted — all writes made during the turn (put/delete/transaction/sql.exec/setAlarm) commit atomically at end-of-turn; a turn that throws commits nothing.
- Lazy per
get— state is not preloaded on activation. Callgetwhen you need it. - Read-your-writes — reads always reflect prior writes from the same actor.
- Per-actor method serialization — calls to one actor instance are dispatched one at a time, giving you effective ACID at the actor level.
- Values round-trip through a codec. JSON natives plus
Date,Map,Set, typed arrays,ArrayBuffer,BigInt, andRegExpcome back as what you stored. Unstorable values — functions, class instances, circular structures, promises, streams — are rejected with aCodecErrorat the write. - Keys starting with
__telnyx_are reserved for the runtime’s own bookkeeping — writes to them are rejected.
list(options)
Lexicographic key order; reverse: true flips it. Returns a Map to preserve ordering.
transaction(fn)
Atomic batch. fn receives a StorageTransaction with the same get/put/delete/list surface.
fn throws, the buffered writes are discarded — and the enclosing method call
fails with ActorOutputGateError, even if your code catches the rejection. Only the
transaction’s own writes are undone: writes you make after catching the rejection still
commit, but the caller sees the error instead of your return value. A thrown transaction
is not a control-flow tool for “try the write, continue on failure”.
sql
ctx.storage.sql is a private embedded SQLite database for this actor — a separate,
synchronous store beside the key/value surface above. See
SQL storage for the exec /
cursor contract, types, transactions, limits, and durability.
transactionSync(fn)
Atomic SQL transaction: runs fn synchronously, commits its sql.exec writes when it
returns (passing the return value through), rolls all of them back if it throws. fn must
not be async — an async callback throws SqlAsyncTransactionError and commits nothing.
This is the only transaction API for SQL: exec() rejects raw BEGIN / COMMIT /
ROLLBACK / SAVEPOINT with SqlTransactionControlError. Unlike a thrown
transaction(fn), a thrown transactionSync does not poison the
turn: catch it and the method call completes normally.
Related
- Key-Value storage — the
get/put/listguide - SQL storage — the
ctx.storage.sqlguide - Actor Context —
ctx.storagelives here - Alarms — the
setAlarm/getAlarm/deleteAlarmcontract
Actor Namespace
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/namespace.md
env. is an ActorNamespace — the handle on env that your function calls into. It’s declared in telnyx.toml:
idFromName(name, options?)
Deterministic — the same name always lands on the same actor instance. This is the most common call: pick a stable identity (caller E.164, CRM id, room id) and let the platform route to the right state.
locationHint is a placement hint, relevant on first touch only — an actor materializes once, so a hint on a later call can’t move it. The platform may ignore the hint entirely (today it does — the option is accepted and reserved for regional placement). Abstract region codes (the taxonomy can evolve without breaking your code).
newUniqueId(options?)
Random — a fresh, unguessable instance. Use when you want the runtime to mint the identity for you (e.g. a one-shot job actor). The id is generated client-side; keep it (via stub.id) if you’ll need to reach the instance again.
newUniqueId currently fails at call time on the platform (a known runtime issue).
Until it’s fixed, mint an identity yourself and pass it to idFromName.
Both return an Actor Stub. Method dispatch on the stub is provided by the runtime via a Proxy; in TypeScript, run telnyx-edge types to type env. against your class’s public method shape, or hand-roll the narrowing — the Actor Stub page shows both.
Related
- Actor Stub — what
idFromName/newUniqueIdreturn - Configuration — declaring the binding
- Addressing — naming and routing
Actor Stub
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/stub.mdAn
ActorStub is the client handle returned by idFromName / newUniqueId. Calling a method on it is an RPC to that one actor instance.
telnyx-edge types generates the narrowing for every [[actors]] binding in
telnyx.toml — a telnyx-env.d.ts that types env. against your class’s
public method shape. To hand-roll it instead (also the path for a
shared-actor reference, which
ships no local class for codegen to read):
Related
- Actor Namespace — produces stubs
- Base Class — the
fetchhandler a stub can call
Actor Alarms
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/alarms.mdEvery Stateful Actor has a single per-instance alarm — a one-shot timer you can set, replace, read, or clear from inside any method. When the alarm fires, the runtime calls the actor’s
alarm(alarmInfo) handler. Alarms are the way to do deferred work without an external scheduler.
There is one alarm per actor instance, not per method or per key. Setting it again replaces the previous time.
Set and Handle
Delivery Contract
AlarmInfo:
- At-least-once delivery. Plan for the handler to run more than once for the same scheduled time. Make writes idempotent — re-check state in
storagebefore applying effects (as theSessionexample above does), or record a done-marker keyed by the scheduled time. - A failed run is redelivered 3 times, about a second apart. If the handler throws (or exceeds its time budget), the platform re-fires it with
retryCountincremented — 4 attempts total, then the alarm is deleted (getAlarm()returnsnull). There is no signal when that happens. - A failing handler loses its alarm — don’t use
throwas your retry mechanism. Three retries a second apart won’t outlast a real outage. If the deferred work must happen, make the handler tolerate its own errors: catch, record, and re-arm withsetAlarmat a backoff you choose. - Use
info.isRetryto branch. On retry, log it, treat the work as best-effort, or skip already-completed steps.
Patterns
TTL / expiry
Set the alarm when you create the row; on fire, check the row is still expired and delete it. If the row was touched since, reset the alarm instead.Retry backoff
If your method kicks off a flaky external call, set an alarm to retry later; on fire, attempt again and either succeed or set a new alarm with a longer backoff.Session timeout
SeeSession.touch above — every interaction pushes the alarm out by the idle window; the alarm only fires if no one touches the session in time.
Coalesced drain
Because there’s only one alarm per instance, you can use it as a “flush trigger”: set it on the first write, and when it fires, drain the queue and clear it. Subsequent writes during the window just append to the queue.Limits
- One alarm per actor instance. If you need multiple timers, keep a queue in
storageand use the single alarm to drive a scheduler loop. whenis ms since epoch. Don’t pass seconds. Firing precision is about one second — don’t schedule sub-second work with it.- No recurring flag. If you want periodic work, set the next alarm at the end of your
alarmhandler. - The handler has a time budget.
alarm()runs under its own wall-clock cap — larger than the 30-second method budget, on the order of minutes, but still bounded. A run that exceeds it counts as a failure and is redelivered. deleteAll()does not clear the alarm. Keys and the alarm are separate; a pending alarm still fires afterdeleteAll(), and a handler that re-arms itself will keep running against empty state. To decommission an actor:deleteAlarm()first, thendeleteAll().
Next Steps
- Runtime API —
ActorStorage,ActorContext,AlarmInfo - Quick Start — Scaffold, deploy, and curl an Account actor
- Shared Actors — Reach one actor from many functions
Configuration
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/configuration.mdA Stateful Actor project uses the umbrella
telnyx.toml manifest (the same file your [[secrets]] and [telnyx] bindings live in). The actor declaration is a [[actors]] array:
Constraints, enforced at ship time:
binding and type must be identifiers and must
not contain __; type is capped at 32 characters; binding names must be unique
across all bindings in the file, and SECRETS is reserved as a binding name when the
file has a [[secrets]] block. Multiple [[actors]] blocks are allowed — including
two bindings pointing at the same type.
Related
- Actor Namespace — the
env.this declares - Project Structure — the two-export project shape
Errors
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/errors.md
On the caller side, a failed stub call rejects with an
Error whose name identifies
the cause — for example ActorMethodError (your method threw; message preserved),
ActorPrivateMethodError (_-prefixed method), or ActorUnknownMethodError. Branch on
err.name if you need to distinguish them.
Related
- Actor Context —
blockConcurrencyWhile - Execution Model — the output-gate guarantee behind
ActorOutputGateError
Guides
Project Structure
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/project-structure.mdA Stateful Actor project ships from one module that exports two things, wired together by a binding declared in
telnyx.toml.
Two Exports
One module exports the actor class and afetch handler — an ordinary
Edge Compute function that calls the actor through a binding on env. The deploy
pipeline routes each export to the right runtime.
The Binding
env.ACCOUNT is declared in telnyx.toml as an [[actors]] entry — binding is the
property on env, type is the class to instantiate per name:
telnyx.toml actor block.
The Quick Start builds and deploys
exactly this shape — the Account actor plus its fetch handler — end to end.
Next Steps
- Shared actors — reach one actor type from multiple functions
- Addressing — naming and routing instances
- Runtime API — the binding surface
Local Development
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/local-development.mdA plain Edge Compute function is an ordinary program you run and
curl directly — the Functions local-dev guide covers that. A Stateful Actor project is different: the fetch handler reaches its actors through an env. call, and that binding resolves only against a running actor runtime. There is no in-process emulation to fall back on.
telnyx-edge dev supplies the missing runtime. It generates the actor stack your project needs, boots it in Docker, serves your fetch handler on a local port, and hot-reloads on save — so env. calls hit real actors before you ship.
telnyx-edge dev is a development command; it does not deploy anything. When it works locally, ship with telnyx-edge ship.
Prerequisites
-
Docker, running. The stack is a set of containers booted with
docker compose. -
A
telnyx.tomlumbrella project — the two-export shape from Project structure: one module that exports an actor class and afetchhandler, wired by an[[actors]]binding. Runtelnyx-edge devfrom the directory that holds the manifest, or point at it with-f/--from-dir. Without atelnyx.toml, the command errors:The Quick Start scaffolds exactly this shape withtelnyx-edge new-func --actor.
The Loop
From an umbrella project directory:.telnyx/dev, and boots it. Your fetch handler is served on http://localhost:8787 (change it with --port). Exercise it the same way the Quick Start exercises the deployed URL — but against localhost, and with actors resolving locally:
env.ACCOUNT.idFromName(...) call routes to a real actor instance in the local stack — the same code path that runs deployed. Edit your actor class or fetch handler and save; telnyx-edge dev watches the source and hot-reloads the affected runtime. Actor state lives in the stack’s Postgres store, so it survives a reload — alice is still 70 after you edit and save.
Press Ctrl-C to stop watching. The stack keeps running; the command only detaches the file watcher.
What the Stack Contains
telnyx-edge dev writes the stack under .telnyx/dev and runs it with docker compose. It is a local stand-in for the deployed platform, containing:
- the function runtime — serves your
fetchhandler on the published port; - the actor runtime — hosts your actor instances;
- a Dapr sidecar for each runtime, plus Dapr placement — the routing layer that sends each actor name to its owner;
- a Postgres actor state store — where
ctx.storagepersists, so state survives reloads.
env. calls from the function runtime reach the actors in the actor runtime through this layer. This is what a plain func.toml function lacks: it has no local binding emulation, so binding-backed code paths only run once deployed.
Flags
Use
--generate-only to inspect or customize the generated compose stack before booting it, and --no-watch when you want the stack up for an out-of-band test run (for example, from a CI script) rather than an interactive edit loop.
Notes
- Actor state survives reloads. Because
ctx.storagepersists to the stack’s Postgres store, a hot reload does not reset your instances — balances, counters, and any other state carry across saves. Tear the stack down withdocker compose(or remove.telnyx/dev) when you want a clean slate. - Ctrl-C leaves the stack running. It stops the watcher, not the containers. Stop the stack explicitly with
docker composewhen you’re done. - A
telnyx.tomlis required.telnyx-edge devruns umbrella projects. A single-functionfunc.tomlproject has no actors to host — develop those with the Functions local-dev workflow instead.
Next Steps
- Quick Start — scaffold, write, and deploy an
Accountactor end to end - Project structure — the two-export shape
telnyx-edge devruns - Deploy — ship, revisions, and rollback
Shared Actors
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/shared-actors.mdA shared actor is a single Stateful Actor type reachable from two or more Edge Compute functions on the same account. The function that ships the actor class owns the actor; other functions on the same account declare the same
type under their own binding name and ship no class — their binding routes to the owner’s actor and its state.
Why Share an Actor
- Split read and write paths into separate functions with their own auth, rate limits, and deploy cadence — without duplicating state.
- Let a second function call into a domain the first one owns (e.g., a reporting function reads an account actor that a payments function owns).
- Keep one durable actor behind many entrypoints. Both functions talk to the same per-name state; writes through one binding are visible to reads through the other.
The Pattern
The owner function is a normal Stateful Actor project — it ships the class. The reference function declares the sametype under a different binding and ships no actor class — its binding routes to the owner’s actor and state; no second instance is created.
There’s no new-func flag for “reference-only,” so the workflow is: scaffold with --actor (to mint a func_id and write telnyx.toml), then drop the actor class and rename the binding.
Walkthrough: a Read-Only Account Reference
Prereq: an owner function for the type already shipped on your account (theaccount from the Quick Start, type Account).
1. Mint a func_id and scaffold, then drop the class
2. Point telnyx.toml at the owner’s type under a new binding
Edit the [[actors]] block — keep type equal to the owner’s type, change binding:
name, main, compatibility_date, and the [edge_compute] func_id as scaffolded.
3. Replace src/index.ts with a class-free handler that calls env.READONLY
export class Account in this file. The bundler roots at main, so with nothing importing the class, the reference bundle ships class-free (a few KB) — that’s what makes it reference-only.
4. Install deps and ship (same as the owner)
5. Prove they share one actor
ACCOUNT, read through READONLY, same balance → one durable actor (Account), two functions. Both must be on the same account; the reference’s type must exactly match the owner’s.
Rules of Thumb
- Same account, same
type. Reference bindings whosetypedoesn’t match an owner already on the account won’t resolve. - Only one function owns the class. The owner ships the actor class; every other function declares the same
typeand ships no class. - Renaming the binding is fine. Each function picks its own
bindingname — that’s the property onenvfor that function. Thetypeis what glues them together. - Reference bundles are small. With no class import, the bundler emits a class-free bundle. Keep it that way: don’t import the actor class from your reference function.
Next Steps
- Quick Start — ship the owner function this reference calls into
- Runtime API —
ActorNamespace,ActorStub, and the binding surface - Alarms — schedule deferred work from inside an actor method
When to Use Stateful Actors
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/when-to-use.mdUse a stateful actor when an entity has state that must be mutated coherently across many requests — a cart, a chat room, a call leg’s media state, a per-user rate limiter, a job coordinator. One instance per name gives you serialized, durable, private state for that entity without locks or a shared database.
When Not to Reach for One
- Stateless work (routing, transforms, independent fan-out): use a plain function. An actor only adds a routing hop and a serialization point you don’t need.
- A global counter or singleton: one name is one single-threaded instance is one
throughput ceiling. Route everything through
idFromName("global")and you’ve rebuilt the single-lock bottleneck. Shard across many names so load spreads across instances. - Large blobs or bulk scans: the keyspace holds one entity’s working set, not a warehouse. Use object storage or a database.
- Cross-entity transactions or ad-hoc queries: each instance is its own
consistency domain. Within one actor, writes are serialized and
transaction()gives you atomic multi-key updates — but there is no transaction across two actors, and there’s noJOINacross them. If you need a transaction spanning many entities, or to query across them, that’s what a database is for — keep it.
1 / (method wall-time) calls per second — the same ceiling one thread per account
gives you. Sharding by a well-chosen name is how you scale.
Next Steps
- How it works — the execution model and its limits
- Addressing — choosing names that spread load
- Shared actors — one actor, many functions
Key-Value
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/storage/key-value.mdEvery Stateful Actor has private, durable key/value storage at
this.ctx.storage. It is
the default place to keep an entity’s state: a cart’s items, a call leg’s status, a
per-user counter. Reach for it first, and drop to SQL
only when a lookup by key isn’t enough and you’d otherwise scan.
The store is private, strongly consistent, and transactional: only this actor instance
reads and writes its keys, its methods run one at a time
(serialized turns), and a
write that returns successfully is durable — it survives the actor being evicted, relocated,
or its pod replaced. Reads always reflect your prior writes.
This is a guide. For the exact method signatures, list options, and codec rules, see the
Actor Storage reference.
Reading and Writing
get and put are the whole surface for simple state. Both are async — they may cross the
network — so await them:
get — nothing is preloaded when the actor activates; you read a key
when you need it. Values round-trip through a codec, so JSON natives plus Date, Map,
Set, typed arrays, ArrayBuffer, BigInt, and RegExp come back as what you stored.
Functions, class instances, promises, and circular structures are rejected at the write.
Keys are strings. Those starting with __telnyx_ are reserved for the runtime — writes to
them are rejected.
Listing Keys
list returns a Map in lexicographic key order, which makes prefixes a natural grouping:
limit defaults to 128 and maxes at 1000. Use start / startAfter / end to page
through a large keyspace. See the reference
for the full ListOptions.
Atomic Updates
transaction batches multiple keys into one atomic unit — all of the writes land, or none
do:
Clearing an Actor
deleteAll() atomically removes every key for this actor. It does not clear a pending
alarm — delete that separately if you want the
actor fully reset.
Key/Value or SQL?
Both live underthis.ctx.storage, are durable and strongly consistent, and can be used
together in the same actor.
- Key/value (this page): simple keyed state, prefix listing, atomic multi-key updates. The default.
- SQL: relational shape,
aggregates,
GROUP BY, secondary indexes, and joins within one actor — when a keyed lookup isn’t enough.
Related
- SQL storage — the
ctx.storage.sqlguide - Actor Storage reference — full method signatures and codec rules
- Alarms — scheduled self-wakeups
SQL
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/storage/sql.mdEvery Stateful Actor has a private, durable embedded SQLite database reachable at
this.ctx.storage.sql. It sits beside the key/value store
under the same this.ctx.storage, but it is a separate engine: one SQLite database per
actor instance, queried with real SQL.
Reach for it when a single entity’s state is relational or needs queries the key/value
store can’t answer without a scan — aggregates, GROUP BY, secondary indexes, joins
within the one actor. It is not a shared database: each instance sees only its own data,
and there are no cross-actor queries or joins.
The database is private, strongly consistent, and transactional: one actor instance is
the only reader and writer of its database, its methods run one at a time
(serialized turns), and a
write that returns successfully is durable at turn granularity — it survives the actor being evicted, relocated,
or its pod replaced. Reads always reflect your prior writes.
Stateful Actors are in Beta. ctx.storage.sql requires @telnyx/edge-runtime 0.6.0 or
later; the transactionSync() type ships in 0.8.0, so use the latest SDK.
The Surface
exec is synchronous — it does not return a Promise. This is deliberate and unlike
every other member of ctx.storage: the database is a local file in the actor’s own
process, so there is no network hop to await.
ctx.storage.sql gets a
database, created on first use. Create your tables with CREATE TABLE IF NOT EXISTS (a
cheap no-op once they exist) at the top of the methods that need them.
Binding Parameters
Use positional? placeholders and pass the values as trailing arguments. Always bind
user input — never build SQL by string concatenation. (Binding here is SQL’s own term for
attaching values to placeholders — no relation to the telnyx.toml bindings your function
receives on env.)
null, number, string, or ArrayBuffer
(for binary). Result columns come back as those same four types:
Reading Results: the Cursor
exec returns a SqlCursor — a single-pass iterator over the result rows. Drain it
once, either by iterating or with toArray():
toArray() on that same cursor returns []; calling toArray() a second time
also returns []. If you iterate partway and then call toArray(), you get the rest of
the rows. Run exec again for a fresh pass.
exec is generic in the row type — exec(...) types the returned rows — but the
type is a compile-time assertion only, not validated at runtime, so make sure your query
returns those columns.
Multiple Statements in One exec
A query string may contain several ;-separated statements — useful for pasting a schema
or a small migration. Every statement executes, in order; the bound parameters apply to
the last statement, and the returned cursor is over the last statement’s rows:
exec throws, but the statements before it
have already been applied. Wrap the batch in a transactionSync callback
when it must be all-or-nothing.
Multi-statement splitting is a best-effort TypeScript tokenizer, not exact SQLite
parsing. It handles all realistic SQL, but unquoted keyword-identifiers (a column named
end, begin, or case in a deep position) can confuse the splitter. Quote
reserved-word identifiers ("end") to avoid edge cases. Exact parity is tracked in
COMPUTE-470.
Transactions
Wrap related writes so they commit together — or roll back together — withtransactionSync. It runs your callback synchronously, commits when it returns, and rolls
back every write if it throws:
transactionSync passes it through, so you can read
back a result computed inside the transaction.
Two rules, both enforced by the runtime:
transactionSyncis the only transaction API. Raw transaction-control statements —BEGIN,COMMIT,ROLLBACK,SAVEPOINT— are rejected byexec()withSqlTransactionControlError, anywhere in a statement batch. (An open transaction left dangling across method calls would have no owner; the callback shape makes that impossible.)- The callback must be synchronous. Passing an
asyncfunction throwsSqlAsyncTransactionErrorand commits nothing — the transaction commits when the callback returns, so statements after anawaitwould run outside it. Do async work before or after, not inside.
Bulk Writes
Each standaloneexec write commits and flushes to disk on its own, which is slow in a
loop. Wrap a batch in one transactionSync and it commits (and flushes) once:
Indexes
The database holds one entity’s working set, but scans still cost time and — as it grows — memory. Add an index on any column you filter or order by frequently:Limits
- 1 GB per actor database. Enforced by the engine — writes past it fail.
- 2 MiB per bound value. A bind larger than 2 MiB (2,097,152 bytes) is rejected
with
SqlParameterTooLargeErrorbefore the statement runs. There is no cap on output row size. - Up to 32,766 bound parameters in a single statement (SQLite’s variable limit).
- Integers come back as JavaScript
number. SQLite stores 64-bit integers, butexecreturns integer columns as JS numbers — the same design as Cloudflare’s SQLite storage and D1, neither of which returnsBigIntfrom the query API. A value beyondNumber.MAX_SAFE_INTEGER(2^53 − 1, i.e.9007199254740991) can be written and compared in SQL, but reading that column throwsRangeError: Value is too large to be represented as a JavaScript number— a loud failure rather than a silently less-precise number. Store large ids, bitmasks, or nanosecond timestamps asTEXT(orBLOB), or keep integers within the safe range. - Foreign keys are ON — a
REFERENCESviolation raisesFOREIGN KEY constraint failed. - Failed statements (syntax errors, constraint violations) throw from
exec; the actor keeps running, so catch and handle them like any other error.
SQL or Key/Value?
Both live underthis.ctx.storage, are durable and strongly consistent, and can be used
together in the same actor.
- Key/value
(
get/put/list): simple keyed state, prefix listing, atomic multi-key updates — reach for it by default when a lookup by key is all you need. - SQL (
ctx.storage.sql): relational shape, aggregates,GROUP BY, secondary indexes, and joins within one actor. Reach for it when a keyed lookup isn’t enough and you’d otherwise scan.
Related
- Key-Value storage — the
get/put/listsurface - Actor Storage reference — the full
ctx.storagecontract - Execution model — why
execneeds no lock
WebSockets
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/websockets.mdWebSocket 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. 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
Connection Lifecycle & Limits
Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/websockets/lifecycle.mdWebSocket support for Stateful Actors is in beta. APIs and limits may change before general availability. One contract underlies everything on this page: every connection ends. A redeploy severs it, the duration budget expires it, a failing handler closes it. Build for that from the start — treat the socket as disposable transport and the actor as the durable thing. Reconnecting is the client’s job;
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