Implementing a Promise-Based Actor Model: Message Passing Concurrency for Complex Frontend State Machines
Implementing a Promise-Based Actor Model: Message Passing Concurrency for Complex Frontend State Machines
Why the Actor Model in the Frontend?
Frontend applications increasingly resemble distributed systems: a main thread coordinating with Web Workers, Service Workers, iframes, and server connections. Shared mutable state (Redux stores, global variables) creates race conditions, debugging nightmares, and tight coupling. The Actor Model — invented by Carl Hewitt in 1973 — offers a fundamentally different approach: isolated units of computation (actors) that communicate exclusively through asynchronous message passing.
Each actor has: (1) private state no one else can read or mutate, (2) a mailbox queue of incoming messages, (3) a behavior function that processes one message at a time. This means no locks, no race conditions on shared state, and a clear supervision hierarchy for error recovery. This post builds a complete actor system with typed messages, mailbox backpressure, supervision trees, location transparency across threads, dead letter handling, and a checkout flow example.
Actor Model Architecture
ACTOR MODEL — CORE CONCEPTS:
════════════════════════════
┌─────────────────────────────────────────────┐
│ ACTOR SYSTEM │
│ │
│ ┌───────────┐ message ┌───────────┐ │
│ │ Actor A │ ───────→ │ Actor B │ │
│ │ ┌───────┐ │ │ ┌───────┐ │ │
│ │ │ State │ │ │ │ State │ │ │
│ │ └───────┘ │ │ └───────┘ │ │
│ │ ┌───────┐ │ │ ┌───────┐ │ │
│ │ │Mailbox│ │ │ │Mailbox│ │ │
│ │ │ msg1 │ │ │ │ msg1 │ │ │
│ │ │ msg2 │ │ │ │ msg2 │ │ │
│ │ │ msg3 │ │ │ │ msg3 │ │ │
│ │ └───────┘ │ │ └───────┘ │ │
│ └───────────┘ └───────────┘ │
│ │ │ │
│ │ supervises │ supervises │
│ ▼ ▼ │
│ ┌───────────┐ ┌───────────┐ │
│ │ Actor C │ │ Actor D │ │
│ └───────────┘ └───────────┘ │
│ │
│ RULES: │
│ 1. Actors process ONE message at a time │
│ 2. No shared state — only messages │
│ 3. Actors can create child actors │
│ 4. Parents supervise children (restart/ │
│ stop on failure) │
│ 5. Dead letters for undeliverable msgs │
└─────────────────────────────────────────────┘
Core Actor System
// === Message Types ===
interface Message {
type: string;
payload?: unknown;
sender?: ActorRef;
correlationId?: string;
timestamp: number;
}
interface ActorRef {
id: string;
send(message: Message): Promise<void>;
ask<T>(message: Message, timeout?: number): Promise<T>;
stop(): void;
}
type Behavior<TState> = (
state: TState,
message: Message,
context: ActorContext
) => Promise<TState | void>;
// === Actor Context ===
interface ActorContext {
self: ActorRef;
parent: ActorRef | null;
system: ActorSystem;
spawn<TState>(
name: string,
behavior: Behavior<TState>,
initialState: TState,
options?: ActorOptions
): ActorRef;
children: Map<string, ActorRef>;
log(level: "info" | "warn" | "error", message: string): void;
}
interface ActorOptions {
mailboxCapacity?: number; // Max messages in queue (backpressure)
supervisionStrategy?: SupervisionStrategy;
location?: "main" | "worker"; // Where to run this actor
}
// === Mailbox with Backpressure ===
class Mailbox {
private queue: Message[] = [];
private capacity: number;
private waiters: Array<(msg: Message) => void> = [];
private backpressureWaiters: Array<() => void> = [];
constructor(capacity: number = 1000) {
this.capacity = capacity;
}
get size(): number {
return this.queue.length;
}
get isFull(): boolean {
return this.queue.length >= this.capacity;
}
/**
* Enqueue a message. If the mailbox is full, the returned
* promise blocks until space is available (backpressure).
*/
async enqueue(message: Message): Promise<void> {
if (this.queue.length >= this.capacity) {
// Backpressure: wait until space opens up
await new Promise<void>((resolve) => {
this.backpressureWaiters.push(resolve);
});
}
// If someone is waiting for a message, deliver directly:
if (this.waiters.length > 0) {
const waiter = this.waiters.shift()!;
waiter(message);
return;
}
this.queue.push(message);
}
/**
* Dequeue the next message. If the mailbox is empty,
* the returned promise waits until a message arrives.
*/
async dequeue(): Promise<Message> {
if (this.queue.length > 0) {
const message = this.queue.shift()!;
// Release backpressure waiter if any:
if (this.backpressureWaiters.length > 0) {
const waiter = this.backpressureWaiters.shift()!;
waiter();
}
return message;
}
// Wait for the next message:
return new Promise<Message>((resolve) => {
this.waiters.push(resolve);
});
}
/**
* Drain the mailbox (for shutdown).
*/
drain(): Message[] {
const remaining = [...this.queue];
this.queue = [];
return remaining;
}
clear(): void {
this.queue = [];
// Reject all waiters:
this.waiters = [];
this.backpressureWaiters.forEach((w) => w());
this.backpressureWaiters = [];
}
}
Actor Implementation
class Actor<TState> implements ActorRef {
readonly id: string;
private state: TState;
private behavior: Behavior<TState>;
private mailbox: Mailbox;
private context: ActorContext;
private running = false;
private children = new Map<string, Actor<any>>();
private pendingAsks = new Map<
string,
{ resolve: (value: any) => void; reject: (error: Error) => void; timer: number }
>();
// Supervision:
private supervisionStrategy: SupervisionStrategy;
private restartCount = 0;
private lastRestartTime = 0;
constructor(
id: string,
behavior: Behavior<TState>,
initialState: TState,
system: ActorSystem,
parent: Actor<any> | null,
options: ActorOptions = {}
) {
this.id = id;
this.behavior = behavior;
this.state = initialState;
this.mailbox = new Mailbox(options.mailboxCapacity ?? 1000);
this.supervisionStrategy =
options.supervisionStrategy ?? { type: "restart", maxRetries: 3, withinMs: 60000 };
this.context = {
self: this,
parent: parent,
system,
spawn: <TChildState>(
name: string,
childBehavior: Behavior<TChildState>,
childState: TChildState,
childOptions?: ActorOptions
) => {
const childId = `${this.id}/${name}`;
const child = new Actor(
childId,
childBehavior,
childState,
system,
this,
childOptions
);
this.children.set(childId, child);
system.register(child);
child.start();
return child;
},
children: this.children as Map<string, ActorRef>,
log: (level, message) => {
system.log(level, `[${this.id}] ${message}`);
},
};
}
/**
* Start processing messages from the mailbox.
*/
start(): void {
if (this.running) return;
this.running = true;
this.processLoop();
}
/**
* Send a fire-and-forget message to this actor.
*/
async send(message: Message): Promise<void> {
if (!this.running) {
this.context.system.deadLetter(message, this.id);
return;
}
await this.mailbox.enqueue(message);
}
/**
* Send a message and wait for a response (request-response pattern).
*/
async ask<T>(message: Message, timeout = 5000): Promise<T> {
const correlationId =
`ask-${Date.now()}-${Math.random().toString(36).slice(2)}`;
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => {
this.pendingAsks.delete(correlationId);
reject(new Error(`Ask timeout for ${message.type} to ${this.id}`));
}, timeout);
this.pendingAsks.set(correlationId, { resolve, reject, timer });
this.send({
...message,
correlationId,
timestamp: Date.now(),
});
});
}
/**
* Stop this actor and all children.
*/
stop(): void {
this.running = false;
// Stop all children first:
for (const child of this.children.values()) {
child.stop();
}
this.children.clear();
// Drain mailbox → dead letters:
const remaining = this.mailbox.drain();
for (const msg of remaining) {
this.context.system.deadLetter(msg, this.id);
}
// Cancel pending asks:
for (const [, pending] of this.pendingAsks) {
clearTimeout(pending.timer);
pending.reject(new Error(`Actor ${this.id} stopped`));
}
this.pendingAsks.clear();
this.context.system.unregister(this.id);
}
/**
* Main message processing loop.
* Processes one message at a time — key actor model guarantee.
*/
private async processLoop(): Promise<void> {
while (this.running) {
try {
const message = await this.mailbox.dequeue();
// Handle ask responses:
if (
message.type === "__ask_response" &&
message.correlationId &&
this.pendingAsks.has(message.correlationId)
) {
const pending = this.pendingAsks.get(message.correlationId)!;
clearTimeout(pending.timer);
this.pendingAsks.delete(message.correlationId);
pending.resolve(message.payload);
continue;
}
// Process with behavior:
const newState = await this.behavior(this.state, message, this.context);
if (newState !== undefined) {
this.state = newState;
}
// If the message had a correlationId and sender, send response:
// (handled by the behavior via context.self)
} catch (error) {
await this.handleFailure(error as Error);
}
}
}
/**
* Handle actor failure using the supervision strategy.
*/
private async handleFailure(error: Error): Promise<void> {
this.context.log("error", `Failed: ${error.message}`);
const parent = this.context.parent as Actor<any> | null;
if (parent) {
await parent.handleChildFailure(this, error);
} else {
// Root actor: apply own strategy
this.applyStrategy(error);
}
}
/**
* Handle a child actor's failure.
*/
async handleChildFailure(child: Actor<any>, error: Error): Promise<void> {
const strategy = this.supervisionStrategy;
switch (strategy.type) {
case "restart":
this.restartChild(child, strategy);
break;
case "stop":
child.stop();
this.children.delete(child.id);
break;
case "escalate":
// Pass the failure up to this actor's parent:
await this.handleFailure(error);
break;
case "one-for-all":
// Restart ALL children when one fails:
for (const [, sibling] of this.children) {
(sibling as Actor<any>).restartSelf();
}
break;
}
}
private restartChild(child: Actor<any>, strategy: SupervisionStrategy): void {
const now = Date.now();
// Reset counter if outside the window:
if (now - child.lastRestartTime > (strategy.withinMs ?? 60000)) {
child.restartCount = 0;
}
child.restartCount++;
child.lastRestartTime = now;
if (child.restartCount > (strategy.maxRetries ?? 3)) {
this.context.log(
"error",
`Child ${child.id} exceeded max retries (${strategy.maxRetries}), stopping`
);
child.stop();
this.children.delete(child.id);
return;
}
this.context.log("info", `Restarting child ${child.id} (attempt ${child.restartCount})`);
child.restartSelf();
}
private restartSelf(): void {
// Keep the mailbox, reset state:
this.state = undefined as any; // Will be re-initialized by preStart message
this.send({ type: "__preStart", timestamp: Date.now() });
}
private applyStrategy(error: Error): void {
this.restartCount++;
if (this.restartCount > (this.supervisionStrategy.maxRetries ?? 3)) {
this.context.log("error", `Max retries exceeded, stopping`);
this.stop();
return;
}
this.restartSelf();
}
}
// === Supervision Strategies ===
interface SupervisionStrategy {
type: "restart" | "stop" | "escalate" | "one-for-all";
maxRetries?: number;
withinMs?: number;
}
Actor System
class ActorSystem {
private actors = new Map<string, Actor<any>>();
private deadLetters: Message[] = [];
private deadLetterListeners: Array<(msg: Message, targetId: string) => void> = [];
private logListeners: Array<(level: string, msg: string) => void> = [];
private workerBridge?: WorkerBridge;
constructor(private name: string = "default") {}
/**
* Create a root-level actor.
*/
spawn<TState>(
name: string,
behavior: Behavior<TState>,
initialState: TState,
options?: ActorOptions
): ActorRef {
const id = `/${name}`;
// If the actor should run in a Worker:
if (options?.location === "worker" && this.workerBridge) {
return this.workerBridge.spawnRemote(id, behavior, initialState, options);
}
const actor = new Actor(id, behavior, initialState, this, null, options);
this.actors.set(id, actor);
actor.start();
return actor;
}
register(actor: Actor<any>): void {
this.actors.set(actor.id, actor);
}
unregister(id: string): void {
this.actors.delete(id);
}
/**
* Look up an actor by path (e.g., "/checkout/payment").
*/
lookup(path: string): ActorRef | undefined {
return this.actors.get(path);
}
/**
* Handle undeliverable messages.
*/
deadLetter(message: Message, targetId: string): void {
this.deadLetters.push(message);
for (const listener of this.deadLetterListeners) {
listener(message, targetId);
}
// Cap dead letter queue:
if (this.deadLetters.length > 10000) {
this.deadLetters = this.deadLetters.slice(-5000);
}
}
onDeadLetter(listener: (msg: Message, targetId: string) => void): void {
this.deadLetterListeners.push(listener);
}
log(level: string, message: string): void {
for (const listener of this.logListeners) {
listener(level, message);
}
if (level === "error") {
console.error(message);
}
}
onLog(listener: (level: string, msg: string) => void): void {
this.logListeners.push(listener);
}
/**
* Enable Worker-based actor hosting.
*/
enableWorkers(workerUrl: string): void {
this.workerBridge = new WorkerBridge(this, workerUrl);
}
/**
* Shut down the entire system.
*/
shutdown(): void {
for (const actor of this.actors.values()) {
actor.stop();
}
this.actors.clear();
this.workerBridge?.terminate();
}
getStats(): {
actorCount: number;
deadLetterCount: number;
actorPaths: string[];
} {
return {
actorCount: this.actors.size,
deadLetterCount: this.deadLetters.length,
actorPaths: [...this.actors.keys()],
};
}
}
Location Transparency: Worker Bridge
LOCATION TRANSPARENCY:
═════════════════════
Main Thread Web Worker
┌──────────────────┐ ┌──────────────────┐
│ Actor A (proxy) │ postMsg │ Actor A (real) │
│ ┌──────────┐ │ ──────────→ │ ┌──────────┐ │
│ │ send() │────┼─────────────┼→│ mailbox │ │
│ └──────────┘ │ │ └──────────┘ │
│ │ │ ┌──────────┐ │
│ │ ←────────── │ │ behavior │ │
│ ┌──────────┐ │ postMsg │ └──────────┘ │
│ │ result │←──┼─────────────┼── │
│ └──────────┘ │ │ │
└──────────────────┘ └──────────────────┘
The proxy ActorRef on main thread serializes messages
and sends them via postMessage. The real actor in the
Worker processes them. Responses come back the same way.
Callers don't know (or care) where the actor runs.
class WorkerBridge {
private worker: Worker;
private pendingMessages = new Map<
string,
{ resolve: (value: any) => void; reject: (error: Error) => void }
>();
private proxies = new Map<string, ActorRef>();
constructor(private system: ActorSystem, workerUrl: string) {
this.worker = new Worker(workerUrl);
this.worker.onmessage = (event) => this.handleWorkerMessage(event.data);
}
/**
* Create a proxy ActorRef that forwards messages to the Worker.
*/
spawnRemote<TState>(
id: string,
behavior: Behavior<TState>,
initialState: TState,
options?: ActorOptions
): ActorRef {
// Tell the Worker to create the actor:
this.worker.postMessage({
type: "spawn",
id,
// Note: behavior can't be serialized — in practice, the Worker
// has its own behavior registry. We send a behavior name instead.
behaviorName: id,
initialState: JSON.parse(JSON.stringify(initialState)),
options,
});
// Create a proxy:
const proxy: ActorRef = {
id,
send: async (message: Message) => {
this.worker.postMessage({
type: "send",
targetId: id,
message: this.serializeMessage(message),
});
},
ask: <T>(message: Message, timeout = 5000): Promise<T> => {
const correlationId =
`worker-ask-${Date.now()}-${Math.random().toString(36).slice(2)}`;
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => {
this.pendingMessages.delete(correlationId);
reject(new Error(`Worker ask timeout: ${message.type}`));
}, timeout);
this.pendingMessages.set(correlationId, {
resolve: (value) => {
clearTimeout(timer);
resolve(value);
},
reject,
});
this.worker.postMessage({
type: "ask",
targetId: id,
correlationId,
message: this.serializeMessage(message),
});
});
},
stop: () => {
this.worker.postMessage({ type: "stop", targetId: id });
this.proxies.delete(id);
},
};
this.proxies.set(id, proxy);
return proxy;
}
private handleWorkerMessage(data: any): void {
switch (data.type) {
case "ask_response":
const pending = this.pendingMessages.get(data.correlationId);
if (pending) {
this.pendingMessages.delete(data.correlationId);
pending.resolve(data.payload);
}
break;
case "send_to_main":
// Worker actor wants to send to a main-thread actor:
const target = this.system.lookup(data.targetId);
if (target) {
target.send(data.message);
} else {
this.system.deadLetter(data.message, data.targetId);
}
break;
case "log":
this.system.log(data.level, data.message);
break;
}
}
private serializeMessage(message: Message): any {
// Strip non-serializable fields:
return {
type: message.type,
payload: message.payload,
correlationId: message.correlationId,
timestamp: message.timestamp,
// sender ActorRef can't be serialized — use sender id instead:
senderId: message.sender?.id,
};
}
terminate(): void {
this.worker.terminate();
for (const [, pending] of this.pendingMessages) {
pending.reject(new Error("Worker terminated"));
}
this.pendingMessages.clear();
}
}
Real-World Example: Checkout Flow
CHECKOUT FLOW — ACTOR HIERARCHY:
═══════════════════════════════
/checkout (supervisor: one-for-all restart)
├── /checkout/cart
│ Manages items, quantities, totals
├── /checkout/payment
│ Handles payment method, validation, tokenization
├── /checkout/shipping
│ Address validation, shipping rate calculation
└── /checkout/order
Orchestrates submission, confirmation
Message flow:
User clicks "Place Order"
→ /checkout receives { type: "PLACE_ORDER" }
→ Sends { type: "VALIDATE" } to /cart, /payment, /shipping
→ Each responds with { type: "VALIDATED", valid: true/false }
→ If all valid → sends { type: "SUBMIT" } to /order
→ /order sends { type: "CHARGE" } to /payment
→ On success → { type: "ORDER_CONFIRMED" }
// === Cart Actor ===
interface CartState {
items: Array<{ id: string; name: string; price: number; qty: number }>;
total: number;
}
const cartBehavior: Behavior<CartState> = async (state, message, ctx) => {
switch (message.type) {
case "ADD_ITEM": {
const { id, name, price, qty } = message.payload as any;
const existing = state.items.find((i) => i.id === id);
let newItems: CartState["items"];
if (existing) {
newItems = state.items.map((i) =>
i.id === id ? { ...i, qty: i.qty + qty } : i
);
} else {
newItems = [...state.items, { id, name, price, qty }];
}
const total = newItems.reduce((s, i) => s + i.price * i.qty, 0);
ctx.log("info", `Added ${name} (${qty}x). Total: $${total.toFixed(2)}`);
return { items: newItems, total };
}
case "REMOVE_ITEM": {
const { id } = message.payload as any;
const newItems = state.items.filter((i) => i.id !== id);
const total = newItems.reduce((s, i) => s + i.price * i.qty, 0);
return { items: newItems, total };
}
case "VALIDATE": {
const valid = state.items.length > 0 && state.total > 0;
if (message.sender) {
await message.sender.send({
type: "CART_VALIDATED",
payload: { valid, total: state.total, itemCount: state.items.length },
correlationId: message.correlationId,
timestamp: Date.now(),
});
}
return state;
}
case "GET_STATE": {
if (message.sender) {
await message.sender.send({
type: "__ask_response",
payload: state,
correlationId: message.correlationId,
timestamp: Date.now(),
});
}
return state;
}
default:
ctx.log("warn", `Unknown message: ${message.type}`);
return state;
}
};
// === Payment Actor ===
interface PaymentState {
method: "card" | "paypal" | null;
cardToken: string | null;
validated: boolean;
}
const paymentBehavior: Behavior<PaymentState> = async (state, message, ctx) => {
switch (message.type) {
case "SET_METHOD": {
const { method } = message.payload as any;
ctx.log("info", `Payment method set to: ${method}`);
return { ...state, method, validated: false };
}
case "SET_CARD_TOKEN": {
const { token } = message.payload as any;
return { ...state, cardToken: token };
}
case "VALIDATE": {
const valid = state.method !== null &&
(state.method !== "card" || state.cardToken !== null);
if (message.sender) {
await message.sender.send({
type: "PAYMENT_VALIDATED",
payload: { valid, method: state.method },
correlationId: message.correlationId,
timestamp: Date.now(),
});
}
return { ...state, validated: valid };
}
case "CHARGE": {
const { amount } = message.payload as any;
ctx.log("info", `Charging $${amount} via ${state.method}`);
// Simulate API call:
await new Promise((r) => setTimeout(r, 1000));
const success = Math.random() > 0.1; // 90% success rate
if (message.sender) {
await message.sender.send({
type: "CHARGE_RESULT",
payload: { success, transactionId: success ? `txn-${Date.now()}` : null },
correlationId: message.correlationId,
timestamp: Date.now(),
});
}
if (!success) throw new Error("Payment failed");
return state;
}
default:
return state;
}
};
// === Shipping Actor ===
interface ShippingState {
address: { street: string; city: string; zip: string } | null;
rate: number;
validated: boolean;
}
const shippingBehavior: Behavior<ShippingState> = async (state, message, ctx) => {
switch (message.type) {
case "SET_ADDRESS": {
const { address } = message.payload as any;
// Calculate shipping rate:
const rate = address.zip.startsWith("9") ? 5.99 : 9.99;
ctx.log("info", `Shipping to ${address.city}: $${rate}`);
return { ...state, address, rate, validated: false };
}
case "VALIDATE": {
const valid = state.address !== null &&
state.address.street.length > 0 &&
state.address.zip.length === 5;
if (message.sender) {
await message.sender.send({
type: "SHIPPING_VALIDATED",
payload: { valid, rate: state.rate },
correlationId: message.correlationId,
timestamp: Date.now(),
});
}
return { ...state, validated: valid };
}
default:
return state;
}
};
// === Checkout Supervisor Actor ===
interface CheckoutState {
status: "idle" | "validating" | "submitting" | "confirmed" | "failed";
validations: Map<string, boolean>;
orderId: string | null;
}
const checkoutBehavior: Behavior<CheckoutState> = async (state, message, ctx) => {
switch (message.type) {
case "__preStart": {
// Spawn child actors:
ctx.spawn("cart", cartBehavior, { items: [], total: 0 });
ctx.spawn("payment", paymentBehavior, {
method: null,
cardToken: null,
validated: false,
});
ctx.spawn("shipping", shippingBehavior, {
address: null,
rate: 0,
validated: false,
});
ctx.log("info", "Checkout system initialized");
return {
status: "idle" as const,
validations: new Map(),
orderId: null,
};
}
case "PLACE_ORDER": {
ctx.log("info", "Starting order placement...");
// Send VALIDATE to all children:
const validations = new Map<string, boolean>();
for (const [name, child] of ctx.children) {
await child.send({
type: "VALIDATE",
sender: ctx.self,
timestamp: Date.now(),
});
}
return { ...state, status: "validating" as const, validations };
}
case "CART_VALIDATED":
case "PAYMENT_VALIDATED":
case "SHIPPING_VALIDATED": {
const { valid } = message.payload as any;
const source = message.type.replace("_VALIDATED", "").toLowerCase();
state.validations.set(source, valid);
ctx.log("info", `${source} validation: ${valid}`);
// Check if all validations are in:
if (state.validations.size >= 3) {
const allValid = [...state.validations.values()].every(Boolean);
if (allValid) {
ctx.log("info", "All validations passed, submitting order...");
// Get cart total and charge:
const cart = ctx.children.get(`${ctx.self.id}/cart`);
const payment = ctx.children.get(`${ctx.self.id}/payment`);
if (cart && payment) {
// In a real system, we'd use ask pattern here
await payment.send({
type: "CHARGE",
payload: { amount: 99.99 }, // Would get from cart
sender: ctx.self,
timestamp: Date.now(),
});
}
return { ...state, status: "submitting" as const };
} else {
ctx.log("warn", "Validation failed");
return { ...state, status: "failed" as const };
}
}
return state;
}
case "CHARGE_RESULT": {
const { success, transactionId } = message.payload as any;
if (success) {
ctx.log("info", `Order confirmed! Transaction: ${transactionId}`);
return {
...state,
status: "confirmed" as const,
orderId: transactionId,
};
} else {
ctx.log("error", "Payment charge failed");
return { ...state, status: "failed" as const };
}
}
default:
// Forward unknown messages to appropriate child:
if (message.type.startsWith("CART_") || message.type === "ADD_ITEM" || message.type === "REMOVE_ITEM") {
const cart = ctx.children.get(`${ctx.self.id}/cart`);
if (cart) await cart.send(message);
} else if (message.type.startsWith("PAYMENT_") || message.type === "SET_METHOD" || message.type === "SET_CARD_TOKEN") {
const payment = ctx.children.get(`${ctx.self.id}/payment`);
if (payment) await payment.send(message);
} else if (message.type.startsWith("SHIPPING_") || message.type === "SET_ADDRESS") {
const shipping = ctx.children.get(`${ctx.self.id}/shipping`);
if (shipping) await shipping.send(message);
}
return state;
}
};
// === Running the System ===
const system = new ActorSystem("ecommerce");
// Monitor dead letters:
system.onDeadLetter((msg, target) => {
console.warn(`Dead letter: ${msg.type} → ${target}`);
});
// Monitor logs:
system.onLog((level, msg) => {
console.log(`[${level.toUpperCase()}] ${msg}`);
});
// Start checkout:
const checkout = system.spawn("checkout", checkoutBehavior, {
status: "idle" as const,
validations: new Map(),
orderId: null,
}, {
supervisionStrategy: { type: "one-for-all", maxRetries: 3, withinMs: 60000 },
});
// Simulate user flow:
async function simulateCheckout() {
// Initialize:
await checkout.send({ type: "__preStart", timestamp: Date.now() });
// Add items:
await checkout.send({
type: "ADD_ITEM",
payload: { id: "1", name: "Widget", price: 29.99, qty: 2 },
timestamp: Date.now(),
});
// Set payment:
await checkout.send({
type: "SET_METHOD",
payload: { method: "card" },
timestamp: Date.now(),
});
await checkout.send({
type: "SET_CARD_TOKEN",
payload: { token: "tok_123" },
timestamp: Date.now(),
});
// Set shipping:
await checkout.send({
type: "SET_ADDRESS",
payload: {
address: { street: "123 Main St", city: "San Francisco", zip: "94102" },
},
timestamp: Date.now(),
});
// Place order:
await checkout.send({ type: "PLACE_ORDER", timestamp: Date.now() });
}
simulateCheckout();
Interview Q&A
Q: What is the Actor Model and why is it useful for frontend state management?
The Actor Model is a concurrency model where the fundamental unit is an actor — an isolated entity with private state, a mailbox queue for incoming messages, and a behavior function. Actors communicate only by sending asynchronous messages; they never share memory. Each actor processes one message at a time, making the behavior naturally sequential — no mutexes, no race conditions on its own state. For frontend applications, this is powerful because modern web apps are increasingly concurrent: user events, API responses, WebSocket messages, Web Worker communication, and animation frames all interleave. With traditional state management (Redux, MobX), you need careful middleware ordering and saga orchestration. With actors, each concern is encapsulated: a "cart actor" owns cart state, a "payment actor" owns payment state, and they coordinate through messages. This also maps perfectly to Web Workers for CPU-intensive tasks — the actor's location (main thread vs. Worker) is transparent to the caller.
Q: How do supervision trees work and why are they important for resilience?
A supervision tree is a hierarchical structure where parent actors are responsible for handling failures in their children. When a child actor throws an exception during message processing, the parent's supervision strategy determines the response. Four common strategies: (1) Restart: reset the failed child's state and resume processing — used when the failure is transient (e.g., network timeout). (2) Stop: terminate the child permanently — used when the child is no longer needed. (3) Escalate: pass the failure up to the grandparent — used when the parent can't handle the failure. (4) One-for-all: restart all children when one fails — used when children have interdependent state (e.g., a checkout flow where cart, payment, and shipping must be consistent). Strategies include backoff parameters: max retries and time windows to prevent infinite restart loops. This is critical for frontend resilience because it makes failure handling declarative and hierarchical rather than scattered across try/catch blocks. Erlang/OTP pioneered this — it's how telephone switches achieve 99.9999999% uptime.
Q: How do you implement backpressure in an actor mailbox?
Backpressure prevents a fast producer from overwhelming a slow consumer. In our mailbox implementation, we set a capacity limit. When enqueue() is called on a full mailbox, instead of dropping the message or growing unbounded, the caller's Promise blocks until space opens up. Internally, the blocked sender is added to a backpressureWaiters array. When dequeue() is called (the actor processes a message), it releases one backpressure waiter, allowing the next enqueue() to complete. This creates natural flow control: if a producer sends messages faster than the actor can process them, the producer automatically slows down. Alternatives include: dropping oldest messages (bounded discard), dropping newest (reject), or buffering to disk. The Promise-based approach is cleanest for frontend code because it works with async/await — the sender just awaits the send and is automatically throttled. The capacity should be tuned: too small causes unnecessary blocking, too large allows memory to grow. Monitoring mailbox depth is a key health metric — a consistently growing mailbox indicates the actor can't keep up.
Q: How does location transparency work with Web Workers?
Location transparency means callers interact with an ActorRef interface (send(), ask(), stop()) without knowing whether the actor runs on the main thread or in a Web Worker. Implementation: when an actor is spawned with location: "worker", instead of creating a real Actor, we create a proxy ActorRef. The proxy's send() serializes the message and calls worker.postMessage(). The proxy's ask() generates a correlation ID, registers a pending Promise, sends the message, and resolves when the Worker sends back a response with the matching correlation ID. On the Worker side, a mirror ActorSystem receives the deserialized message and delivers it to the real actor's mailbox. The challenge is behavior functions can't be serialized via postMessage (they're closures). The solution: the Worker has its own behavior registry. When spawning, the main thread sends a behavior name, and the Worker looks up the corresponding function. This pattern enables offloading heavy computation (diff algorithms, data transformations) to Web Workers while keeping the programming model identical to main-thread actors.
What did you think?