The dead letters, DevTools and the wire release — 828 commits since v0.16.0 and 291 changelog entries, by a wide margin the largest window this project has had (v0.16.0, the previous record, was 188). Three threads run through it. The wire — cluster frames stop being a bare JSON.stringify and become the same tagged JSON tree the persistence layer already wrote, so the framework stops contradicting itself across its own boundaries. Dead letters — an undeliverable message stops vanishing into an event stream nobody subscribed to and gets a bounded, inspectable, replayable queue. DevTools — the UI becomes something you would actually leave open, with four new panels and the ability to stop time.
Fifty-three entries carry a BREAKING marker, each with a migration note. One of them decides how you deploy this, and it is first below.
⚠️ Breaking changes (pre-1.0)
-
A rolling upgrade across this release is not safe in either direction — and this is the second consecutive release for which that is true (#450). Cluster frames are now the tagged JSON tree, applied to the whole frame rather than an envelope's body. The old format was a bare
JSON.stringify, and the cost was that the framework disagreed with itself depending on which boundary a value crossed: aMapan actor could persist and recover verbatim arrived at a peer as{}, aDatearrived as a string whose.getTime()throws, aUint8Arrayarrived as an index-keyed object,NaNand-0arrived asnulland0, and abigintthrew straight out ofTcpTransport.send— which is to say, out of your ownref.tell. One walker now serves HTTP bodies, journal rows and the wire alike, so there is no per-transport list of what a message may contain to keep in sync.Migration. Most legacy traffic decodes unchanged, because
decodeJsonTreereads a tag only when it is an object's sole own key. The exception is a legacy body that already had that shape:__map__,__set__,__regexp__,__bigint__,__url__,__number__and__error__throw at any depth, and a decoder throw costs the whole connection along with every frame batched into the same chunk;__bytes__and__date__fail silently instead, decoding to aUint8Arrayand an Invalid Date. The other direction is plainly lossy — an older node reads the tag wrapper as ordinary data.Upgrade the cluster in one step: stop every node, then start every node on the new version. v0.16.0 already broke gossip compatibility the same way (#112), so a hop from v0.15.x lands on two non-rollable legs and needs the same treatment once rather than twice. The upgrade documentation now carries a per-release compatibility table (#1304), and #823 — a protocol version handshake — is what will make a mixed-version window a supported state rather than a hazard.
-
Cluster.bootstraprejects when readiness is missed (#943, #1086). A resolvedbootstrap()now means a formed cluster.awaitReadywidens toboolean | number | ClusterReadinessOptions, and its default budget covers the self-election grace on every stable-observation node — the old default under-covered N−1 of N nodes on a genuine cold start. On timeout the bootstrap runs the coordinated-shutdown pipeline and rejects withClusterReadyTimeoutErrorinstead of resolving for a node stilljoiningand letting it serve traffic. Migration:awaitReady: falsepluscluster.awaitReady().catch(…)restores the old fire-and-forget shape. -
ActorCellhandles a batch of user messages per dispatcher turn (#409) — worth 2.1×–3.6× ontellthroughput. Configurable per actor throughActorOptions.withThroughput(). -
Three cluster-correctness defects that all had the same shape: two authorities where there should be one. A routine singleton scale-up no longer runs two instances (#949);
KeepMajoritynow downs both sides of an exact 50/50 split rather than returning the empty set and leaving both halves live (#1170); and two nodes that disagree aboutnumShardsare no longer allowed to double-home entities silently, including via a persisted coordinator snapshot that used to route around the refusal (#633). -
system.terminate()drains the actors under/userbefore stopping them (#663).ref.tell('x'); await system.terminate()now deliversx. -
A bounded mailbox's
capacitybounds the messages it may discard, not the messages it holds (#729) — and messages a bounded or priority mailbox discards can now become dead letters (#773) instead of vanishing. -
BrokerActor.onReceiveis sealed (#709). Subclasses implement the new abstractonCommand(command)instead. -
Dead letters name the actor the message failed to reach, on every path (#433).
-
Projections gained a handler-failure recovery strategy and no longer retry a poison event forever (#650).
Forty more are in CHANGELOG.md, each with its own migration note.
🚀 New features
-
A bounded, optionally durable dead-letter queue with inspection and replay (#1000, #433). Undeliverable messages were published to an event stream that nothing subscribed to by default — which meant they produced no output at all, while two documentation pages claimed the system logged them. There is now a real queue, configured through
ActorSystemOptions.withDeadLetters(…), with ametricsstore option, anddeadLetterQueue.replay(id, alternateRecipientPath)to replay a message to a recipient other than the one it was addressed to. -
DevTools grew four panels and a pause button (#482, #553, #1349). The UI is Angular throughout, and gains a dead-letter panel, a live event-stream tail, a resolved-configuration panel showing every HOCON key and where its value came from, and a send-message action that is off by default. Time can be paused and resumed from the header. It also gained roughly 3,000 lines of tests, having previously had none (#487).
-
Cluster operability: wait until the cluster is actually formed (#943);
ClusterOptions.advertisedHostfor nodes behind NAT or a service mesh (#944); warm hand-over for singletons, so a scale-up transfers state instead of rebuilding it; andClusterSharding.shardMap(typeName). -
Persistence:
PersistentActorcan be fenced with a lease (#1166), so a stale instance cannot keep writing;PersistentActorandDurableStateActorgained anintegrity()hook;InMemorySnapshotStoreaccepts akeepNretention bound; Postgres and MariaDB projections read the tags index instead of scanning. -
The framework comparison is complete (#27, #1327). Nine arms across three runtimes — actor-ts, nact and XState on Bun; Akka and Pekko through both their Java and Scala APIs on the JVM; Akka.NET and Orleans on .NET — measured as a hundred interleaved rounds on one Linux machine, every row verified against work the system actually completed rather than work it was asked for. See
RESULTS.md. -
Observability:
actor_mailbox_depth,actor_mailbox_wait_secondsandactor_dispatcher_queue_delay_seconds;/healthand/readyaggregate framework-owned health checks;ActorSystem.runUntilTerminated()owns the whole of a service's shutdown. -
An email bridge actor and HTML email templates (#1133).
🔒 Security
Seventy-one entries — the largest security section this project has shipped. Among them: a ProducerController now stamps a crypto-random per-incarnation token on every Delivery and the ConsumerController refuses one that does not echo it; a replicated event's author is bound to the node that sent it, so a member can no longer permanently suppress another replica's events (#706); object-storage bodies are bound to the storage key they live at, so a durable-state revision cannot silently go backwards; DistributedData gossip is bounded by a per-frame byte budget; a WebSocket route's transport frame cap is the cap you configured, in both directions (#373, #586); a decoded CRDT counter slot is bounded; and the object-storage backends and the master-key rotation sweep now share one key policy (#747).
🐛 Fixed
Ninety-one entries. The tooling half is worth calling out on its own, because it is what makes the rest checkable: typecheck:dev went from 320 errors to zero and became a gated CI job (#540) — it is the only gate that compiles the library from a caller's position, and it immediately turned up five exported declarations no caller could use. Alongside it: the coverage gate stopped being two implementations of one parse, with per-module floors a rollup of per-file percentages cannot express (#541, #1016); the bundled examples now run in CI (#559); the three quarantined multi-node suites run nightly with the skip flag off; and a repeat-run flake harness (bun run test:stress) landed with a Diagnosing flakes page to go with it.
Install: bun add actor-ts · npm install actor-ts
Published with npm provenance via Trusted Publishing (OIDC). Full detail in CHANGELOG.md; what is planned next is in ROADMAP.md.