From vanilla TypeScript
If you’re coming from a “regular” TypeScript codebase — async
functions, EventEmitters, mutable class state, manual try/catch
everywhere — actor-ts looks like a lot of ceremony. This page is
the honest case for when the ceremony pays off, and the
concrete patterns that translate from vanilla TS.
The short version
Section titled “The short version”Reach for actor-ts when:
- State is shared across many concurrent requests and you’ve been writing mutex-like wrappers or accidentally hitting race conditions.
- You need to recover from crashes — a process restart should pick up where the last one left off, not start from zero.
- You’re distributing work across nodes and the “send a message to a remote service” pattern is feeling clunky.
- Failure handling is brittle — try/catch everywhere, no central place to say “if this fails, do X.”
Stay with vanilla TS when:
- The app is mostly request/response, mostly stateless, single process. HTTP handler in, JSON out, no shared mutable state.
- You’re not crash-recovering anything.
- You don’t have multiple concurrent things modifying the same state.
Pattern 1 — Shared mutable state with concurrent writers
Section titled “Pattern 1 — Shared mutable state with concurrent writers”Before
Section titled “Before”class OrderService { private orders = new Map<string, Order>();
async place(order: Order): Promise<void> { if (this.orders.has(order.id)) throw new Error('duplicate'); await this.persistToDB(order); // ← any await releases the loop this.orders.set(order.id, order); // ← can race with another `place` }}Subtle race: between persistToDB and the Map.set, another
place() for the same ID could pass the has check. Result:
two writes to the DB, possibly two Map entries.
You can fix this with a per-key lock, an in-flight set, or a queue. Or:
class OrderActor extends PersistentActor<PlaceCmd, OrderPlaced, OrderState> { readonly persistenceId = 'order-registry';
initialState() { return { orders: {} as Record<string, Order> }; } onEvent(state, e) { if (e.kind === 'placed') return { orders: { ...state.orders, [e.order.id]: e.order } }; return state; } onCommand(state, cmd: PlaceCmd) { if (state.orders[cmd.order.id]) return; // already placed this.persist({ kind: 'placed', order: cmd.order }, () => {}); }}The actor processes commands one at a time. Two PlaceCmds
for the same ID are serialized; the second one sees the state
updated by the first and skips persisting. No locks, no
in-flight sets, no race.
Plus: events are persisted, so a process restart replays them and the in-memory state recovers.
Pattern 2 — EventEmitter + handlers
Section titled “Pattern 2 — EventEmitter + handlers”Before
Section titled “Before”class WorkerPool extends EventEmitter { async run(job: Job): Promise<void> { this.emit('start', job); try { const result = await execute(job); this.emit('done', job, result); } catch (e) { this.emit('error', job, e); } }}
const pool = new WorkerPool();pool.on('done', (job, result) => log(`${job.id} done`));pool.on('error', (job, err) => alerting.fire(`${job.id} failed`));Works, but:
- No supervision — if
executethrows unexpectedly, do listeners still hear about it? - Type safety on listeners is fiddly (
EventEmitterdoesn’t enforce event-name + arg shape). - Hard to add cluster-distribution later.
type JobEvent = | { kind: 'started'; job: Job } | { kind: 'done'; job: Job; result: Result } | { kind: 'failed'; job: Job; error: Error };
class Worker extends Actor<{ kind: 'run'; job: Job; replyTo: ActorRef<JobEvent> }> { override async onReceive(msg): Promise<void> { msg.replyTo.tell({ kind: 'started', job: msg.job }); try { const result = await execute(msg.job); msg.replyTo.tell({ kind: 'done', job: msg.job, result }); } catch (e) { msg.replyTo.tell({ kind: 'failed', job: msg.job, error: e as Error }); } }}Type-safe events. Listener actors are first-class. The actor’s supervisor strategy is the central error-handling point — no listener-on-error gymnastics.
For multi-listener fan-out, use the event stream or DistributedPubSub.
Pattern 3 — Background loop with mutable state
Section titled “Pattern 3 — Background loop with mutable state”Before
Section titled “Before”class HealthMonitor { private status = new Map<string, 'up' | 'down'>();
constructor(private readonly services: string[]) { setInterval(() => this.tick(), 10_000); }
private async tick(): Promise<void> { for (const svc of this.services) { const ok = await this.ping(svc); this.status.set(svc, ok ? 'up' : 'down'); } }
getStatus(svc: string): 'up' | 'down' { return this.status.get(svc) ?? 'down'; }}A long-running object with both state and scheduled work. Issues:
getStatusreads whiletickmight be mid-update — possibly fine for'up' | 'down'but not for richer state.- No supervision — if
pingthrows,setIntervalcontinues but the catch-less code silently breaks. - Restarting the whole thing is awkward — no clean way to “reset this monitor” without reaching into its internals.
type Msg = | { kind: 'tick' } | { kind: 'get-status'; svc: string; replyTo: ActorRef<'up' | 'down'> };
class HealthMonitor extends Actor<Msg> { private status = new Map<string, 'up' | 'down'>();
constructor(private readonly services: string[]) { super(); }
override preStart(): void { this.context.timers.startTimerWithFixedDelay('tick', { kind: 'tick' }, 10_000); }
override async onReceive(msg: Msg): Promise<void> { if (msg.kind === 'tick') { for (const svc of this.services) { const ok = await this.ping(svc); this.status.set(svc, ok ? 'up' : 'down'); } } else if (msg.kind === 'get-status') { msg.replyTo.tell(this.status.get(msg.svc) ?? 'down'); } }}tick runs serialized with get-status — never overlapping.
The timer auto-cancels on stop. If onReceive throws, the
supervisor decides what to do; a fresh preStart re-arms the
timer.
Pattern 4 — Saga / multi-step coordination
Section titled “Pattern 4 — Saga / multi-step coordination”Before
Section titled “Before”async function placeOrder(order: NewOrder): Promise<OrderResult> { const reserved = await inventory.reserve(order.items); try { const charged = await payment.charge(order.customerId, order.total); try { const shipped = await shipping.schedule(order); return { ok: true, shippingId: shipped.id }; } catch (e) { await payment.refund(charged.id); await inventory.release(reserved.id); throw e; } } catch (e) { await inventory.release(reserved.id); throw e; }}Nested try/catch. Compensations buried in catch blocks. Hard to reason about partial failures (what if the refund itself fails?).
After (event-sourced saga)
Section titled “After (event-sourced saga)”type Cmd = { kind: 'place'; order: NewOrder };type Event = | { kind: 'inventory-reserved'; resId: string } | { kind: 'payment-charged'; chargeId: string } | { kind: 'shipping-scheduled'; shippingId: string } | { kind: 'compensating-...' };
class OrderSaga extends PersistentActor<Cmd, Event, OrderSagaState> { // ... async onCommand(state, cmd) { if (cmd.kind === 'place') { const reserved = await inventory.reserve(cmd.order.items); this.persist({ kind: 'inventory-reserved', resId: reserved.id }, async () => { try { const charged = await payment.charge(...); this.persist({ kind: 'payment-charged', chargeId: charged.id }, async () => { // ... and so on }); } catch (e) { // Compensate — events drive the compensation, too: this.persist({ kind: 'compensating-refund-needed' }, () => {}); } }); } }}Each step is persisted before the next — a process crash mid-saga recovers by replaying the event log and resuming. Compensation state is also persisted, so partial-compensation crashes recover cleanly.
This is more code than the try/catch version. Worth it when:
- The saga spans seconds to minutes and crash-recovery matters.
- Compensations are critical (real money / real inventory).
- You’d otherwise be building this on top of a workflow engine anyway.
For shorter sagas (sub-second, in-memory acceptable on restart), the try/catch chain is fine.
What you give up
Section titled “What you give up”A staged adoption strategy
Section titled “A staged adoption strategy”You don’t have to rewrite everything. A reasonable adoption order:
- Start with one stateful concept — the registry that’s been
giving you race conditions. Wrap it in a
PersistentActor. HTTP handlers still call it like a service; they just useaskinstead of method calls. - Add scheduled work that’s been living in
setInterval. Convert to actors withcontext.timers. Supervised, automatic cleanup, testable. - Add cross-process distribution when single-process isn’t enough. Cluster-join + sharding lets the registry from step 1 span N nodes.
- Add event-sourcing for workflows that need recovery — sagas, multi-step user flows, billing pipelines.
Each step is independently valuable. You can stop at any of them and have a more reliable system than the all-vanilla version, with the rest of your code unchanged.
Where to next
Section titled “Where to next”- Quickstart — the “hello actor” walkthrough.
- Why actors — the rationale, in more detail than this page.
- Actor — the base class you’ll extend.
- PersistentActor — the event-sourced variant that replaces “race-prone mutable state.”
- Migration overview — migration guides from other actor frameworks.