Skip to main content

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.md
A 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.
That 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 nameidFromName("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 storage survives eviction or restart.
Execution model states each precisely; How it works derives them from a plain C server. Scaffold, deploy, and curl an actor. The C-vs-actor derivation from first principles. The four guarantees, stated precisely. Where instances run; what survives a restart. Naming and routing instances. The two-export shape. Reach one actor from many functions. And when to reach for something else. The full surface. Scheduled work from inside an actor. Terminate a live socket on the actor — each frame is a serialized turn.
  • 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.md
Ship 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 durable Account actor type — but each account name is its own isolated instance:
A debit never overdraws, and a committed debit is never lost.

Why an Account

An Account exercises the three properties a Stateful Actor gives you:
  • Per-instance isolationalice and bob are 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-edge CLI, installed and authenticated — a release with Stateful Actor deploy support. v0.2.2 and earlier cannot ship an actor project (new-func --actor appears to work, but ship fails); upgrade from the releases page.
  • A Telnyx API key.
  • Node.js (for npm).

1. Authenticate

2. Scaffold the Actor Project

This writes an umbrella 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 with src/account.ts. It holds one value — the balance — in this.ctx.storage:

4. Write the Fetch Handler

Replace src/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 ACCOUNTAccount:

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:
Now exercise it — two accounts, no overdraw, isolation, persistence:
You should see:
  • alice ends at 70 (100 − 30; the 1000 debit was rejected)
  • bob is independent at 5
  • alice is unchanged by bob’s activity (isolation), and survives across requests (persistence)

Next Steps

  • Shared Actors — add a second function that reads/writes the same Account through a different binding
  • Runtime APIStatefulActor, 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.md
Take 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.
Durability is append-to-log then fsync — you don’t acknowledge the debit until the bytes are on disk:
A request names an account; the process looks it up and serves it. Lock that account, check-and-decrement, log, fsync, unlock:
Two primitives do all the work: the lock is the serialization point, and 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 ay;
  • 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’ COMMIT is what persist_data does, its checkpoint is your snapshot, and SELECT … FOR UPDATE is the lock.
You can build all of this. The work isn’t any single line — it’s that you own the lock discipline on every path, the log format, the snapshots, the recovery, the sharding, and the restarts, forever.

The Same Thing as a Stateful Actor

The class is only the logic half. In the C version, 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

Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/concepts/execution-model.md
The 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


Lifecycle & Placement

Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/concepts/lifecycle.md
A 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 like this.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


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 same name always 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 to idFromName.

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


Reference

Overview

Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference.md
The 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:
  • 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 wires this.ctx and this.env, so a subclass that doesn’t need init logic can omit its constructor entirely.
If you do need one-shot init (preload state, set up an initial alarm), override the constructor and call ctx.blockConcurrencyWhile — see Actor Context.

Dispatch Rules

  • Public methods are RPC-callable from a stub — declare them async (every stub call is a Promise on 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’s private keyword 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 on StatefulActor itself (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.
See Alarms for the delivery contract and retry policy.

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:
Callable from a binding as 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. req is 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 (like x-user above) before forwarding.
  • ws is a Node ws socket (import type { WebSocket } from "ws"): ws.on("message", (data, isBinary) => ...), ws.send(data), ws.close(code, reason). There is no WebSocketPair/accept() shape.
  • Register your listeners before webSocket() returns. Frames that arrive before webSocket() 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, and error handlers 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 code 1011.
Connections reach the actor through your function’s 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.

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:
See WebSockets for how sockets reach the actor in the first place.

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.
Key behaviors:
  • Lazy per get — state is not preloaded on activation. Call get when 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, and RegExp come back as what you stored. Unstorable values — functions, class instances, circular structures, promises, streams — are rejected with a CodecError at 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.
If 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.

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.

Actor Stub

Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/stub.md
An 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):

Actor Alarms

Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/alarms.md
Every 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

The handler is just another method call on the actor — it inherits the same single-threaded dispatch guarantee as any other method. You don’t need a lock around the state the alarm touches.

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 storage before applying effects (as the Session example 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 retryCount incremented — 4 attempts total, then the alarm is deleted (getAlarm() returns null). There is no signal when that happens.
  • A failing handler loses its alarm — don’t use throw as 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 with setAlarm at a backoff you choose.
  • Use info.isRetry to 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

See Session.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 storage and use the single alarm to drive a scheduler loop.
  • when is 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 alarm handler.
  • 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 after deleteAll(), and a handler that re-arms itself will keep running against empty state. To decommission an actor: deleteAlarm() first, then deleteAll().

Next Steps


Configuration

Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/configuration.md
A 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.

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.

Guides

Project Structure

Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/project-structure.md
A 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 a fetch 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:
See the Runtime API for the full 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


Local Development

Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/local-development.md
A 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.toml umbrella project — the two-export shape from Project structure: one module that exports an actor class and a fetch handler, wired by an [[actors]] binding. Run telnyx-edge dev from the directory that holds the manifest, or point at it with -f/--from-dir. Without a telnyx.toml, the command errors:
    The Quick Start scaffolds exactly this shape with telnyx-edge new-func --actor.

The Loop

From an umbrella project directory:
This bundles the project, generates the stack under .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:
Each 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 reloadalice 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 fetch handler 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.storage persists, 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.storage persists 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 with docker 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 compose when you’re done.
  • A telnyx.toml is required. telnyx-edge dev runs umbrella projects. A single-function func.toml project has no actors to host — develop those with the Functions local-dev workflow instead.

Next Steps

  • Quick Start — scaffold, write, and deploy an Account actor end to end
  • Project structure — the two-export shape telnyx-edge dev runs
  • Deploy — ship, revisions, and rollback

Shared Actors

Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/shared-actors.md
A 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.
Both functions must be on the same account — shared actors are bounded by account.

The Pattern

The owner function is a normal Stateful Actor project — it ships the class. The reference function declares the same type 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 (the account 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:
Leave 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

Key point: no 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

Write through 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 whose type doesn’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 type and ships no class.
  • Renaming the binding is fine. Each function picks its own binding name — that’s the property on env for that function. The type is 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 APIActorNamespace, 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.md
Use 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 no JOIN across them. If you need a transaction spanning many entities, or to query across them, that’s what a database is for — keep it.
A single instance processes calls serially, so its ceiling is roughly 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


Key-Value

Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/storage/key-value.md
Every 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:
State is lazy per 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:
If the callback throws, its buffered writes are discarded. Note that a thrown transaction also fails the enclosing method call — it is not a “try the write, continue on failure” tool. See the reference for the exact semantics.

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 under this.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.

SQL

Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/storage/sql.md
Every 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.
There is no schema step in the manifest — any actor that calls 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.)
A bound value must be one of four types: 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():
Single-pass means the rows are consumed as you read them. After you iterate a cursor to the end, 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:
Statements run independently — if one fails, 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 — with transactionSync. It runs your callback synchronously, commits when it returns, and rolls back every write if it throws:
The callback returns a value — transactionSync passes it through, so you can read back a result computed inside the transaction. Two rules, both enforced by the runtime:
  • transactionSync is the only transaction API. Raw transaction-control statements — BEGIN, COMMIT, ROLLBACK, SAVEPOINT — are rejected by exec() with SqlTransactionControlError, 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 async function throws SqlAsyncTransactionError and commits nothing — the transaction commits when the callback returns, so statements after an await would run outside it. Do async work before or after, not inside.
Because a single actor instance processes one method at a time, you never contend with another writer inside your own database — the actor is the lock.

Bulk Writes

Each standalone exec 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:
The difference is large: thousands of individual auto-commit inserts take seconds; the same inserts inside one transaction take tens of milliseconds. Batch anything hot.

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 SqlParameterTooLargeError before 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, but exec returns integer columns as JS numbers — the same design as Cloudflare’s SQLite storage and D1, neither of which returns BigInt from the query API. A value beyond Number.MAX_SAFE_INTEGER (2^53 − 1, i.e. 9007199254740991) can be written and compared in SQL, but reading that column throws RangeError: 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 as TEXT (or BLOB), or keep integers within the safe range.
  • Foreign keys are ON — a REFERENCES violation raises FOREIGN 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 under this.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.

WebSockets

Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/websockets.md
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

Connection Lifecycle & Limits

Source: https://developers.telnyx.com/docs/edge-compute/stateful-actors/websockets/lifecycle.md
WebSocket 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’s fetch 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-GET request with an Upgrade header is refused with HTTP 400 — no handshake starts.
  • The first client-offered subprotocol is echoed. Offer ["chat.v2", "chat.v1"] and the connection is accepted with chat.v2. The actor cannot select among the offers today; it observes the echoed value as ws.protocol. Put your preferred protocol first.
  • Compression is never negotiated. A permessage-deflate offer is accepted without the extension; frames flow uncompressed.
  • Text and binary frames both work. The handler’s message listener 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.
Reconnect to the same name. 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