Skip to content

Receive timeout

A receive timeout is the actor-bound equivalent of “this thing has been quiet for too long — do something.” You set a duration; if no user message arrives within that window, the framework delivers a ReceiveTimeout system message to the actor.

import { Actor, ReceiveTimeout } from 'actor-ts';
type Msg = { kind: 'activity' } | ReceiveTimeout;
class Session extends Actor<Msg> {
override preStart(): void {
// Idle for 30 s → fire a ReceiveTimeout.
this.context.setReceiveTimeout(30_000);
}
override onReceive(msg: Msg): void {
if (msg === ReceiveTimeout.instance) {
this.log.info('session idle — stopping');
this.context.stopSelf();
return;
}
// Any user message resets the idle timer.
this.log.info('activity');
}
}

Three things to notice:

  1. setReceiveTimeout(ms) arms the timer. After ms of no user messages, ReceiveTimeout.instance lands in the mailbox.
  2. Every user message resets the timer. The clock starts over from each onReceive invocation. Activity = the actor is alive; silence = the timer counts down.
  3. The timeout fires repeatedly — once delivered, the timer re-arms. An actor that doesn’t process the ReceiveTimeout keeps receiving them every ms. Use cancelReceiveTimeout() or setReceiveTimeout(0) to stop.
interface ActorContext<TMsg> {
setReceiveTimeout(ms: number): void;
cancelReceiveTimeout(): void;
}

Two methods. setReceiveTimeout(0) is equivalent to cancelReceiveTimeout() — the timer is disabled. Passing a positive value (re-)arms it.

class Session extends Actor<SessionMsg | ReceiveTimeout> {
constructor(private readonly userId: string) { super(); }
override preStart(): void {
this.context.setReceiveTimeout(15 * 60_000); // 15 minutes
}
override onReceive(msg: SessionMsg | ReceiveTimeout): void {
if (msg === ReceiveTimeout.instance) {
this.log.info(`session ${this.userId} expired`);
this.context.stopSelf();
return;
}
// Process the session message...
}
}

A per-user session actor that auto-stops after 15 minutes of inactivity. When the user comes back, a fresh actor spawns; the old one is gone. This is the bread-and-butter use of receive timeouts.

class Entity extends Actor<EntityMsg | ReceiveTimeout> {
override preStart(): void {
this.context.setReceiveTimeout(2 * 60_000); // 2 minutes idle
}
override onReceive(msg: EntityMsg | ReceiveTimeout): void {
if (msg === ReceiveTimeout.instance) {
// Tell the shard region we're done — it will stop us cleanly.
this.context.parent.forEach((p) =>
p.tell({ kind: 'passivate', entityId: this.id }));
return;
}
// ...
}
}

Cluster sharding pairs naturally with receive timeouts: an entity that’s been idle for some time signals its region to passivate it, freeing memory. When a new message for the same entity-id arrives, the region re-spawns the entity from its persisted state.

See Sharding for the full passivation protocol.

class Heartbeat extends Actor<...> {
override preStart(): void {
// We expect the upstream to push at least every 30 s.
this.context.setReceiveTimeout(30_000);
this.upstream.tell({ kind: 'subscribe', subscriber: this.self });
}
override onReceive(msg): void {
if (msg === ReceiveTimeout.instance) {
this.log.warn('upstream silent — re-subscribing');
this.upstream.tell({ kind: 'subscribe', subscriber: this.self });
// Don't stop — keep trying.
return;
}
// Process the heartbeat...
}
}

Catching “the upstream stopped sending events” without an explicit ping from this side. The receive timeout is the absence of traffic — it fires precisely when there’s nothing else to react to.

The timer counts user messages only. System messages (PoisonPill, Kill, supervisor signals, internal events) do not reset it. This matters because:

  • An actor with a long-running system-message stream (e.g. receiving Terminated from many watched children) still triggers receive timeouts if no user messages arrive.
  • Likewise, a quiet actor that receives a PoisonPill from outside doesn’t get a “last receive timeout” before stopping — the PoisonPill triggers normal shutdown, the receive-timeout state is cleaned up.

Switching behavior with context.become(...) does not affect the receive timeout. The same ms window applies to whichever handler is currently active. Two phases of an actor share one timeout — re-arm explicitly in become if you want phase-specific durations:

private idle = (msg) => {
if (msg.kind === 'start') {
this.context.become(this.working);
this.context.setReceiveTimeout(60_000); // 1 min idle in working mode
}
};
private working = (msg) => {
if (msg === ReceiveTimeout.instance) {
this.context.become(this.idle);
this.context.setReceiveTimeout(0); // no timeout in idle mode
}
};

ReceiveTimeout.instance is delivered via the same path as any other user message — it’s enqueued, dispatched, handed to onReceive. Three knock-on effects:

  • A backed-up mailbox delays receive timeouts. If the actor has 100 user messages queued and processes them slowly, the ReceiveTimeout from a quiet period earlier might land after some of those messages — by which point the actor isn’t idle anymore. This is usually fine (a busy actor isn’t idle by definition), but the timing isn’t guaranteed.
  • ReceiveTimeout.instance is a singleton, so identity comparison (msg === ReceiveTimeout.instance) is reliable.
  • Restart resets the timer. On restart, the new instance starts with no receive-timeout configured; call setReceiveTimeout in preStart (or postRestart) to re-arm.

The ActorContext.setReceiveTimeout and ReceiveTimeout API references cover the full signatures.