Skip to content

Messaging

Actors never share memory. They send messages. Define messages as classes and narrow ctx.message with instanceof. Each branch is fully typed.

ts
const msg = ctx.message;

if (msg instanceof Greet) {
  console.log(`Hello ${msg.name}`);
} else if (msg instanceof HowMany) {
  ctx.response(this.count);
} else {
  ctx.unhandled();
}

Example: examples/helloworld.

ReceiveContext

ReceiveContext is the single argument receive is called with. It wraps one delivery and is the actor's entire interface to the outside world for that message: it carries the message, tells the actor who sent it and which actor is receiving, and exposes every action the actor can take in response, all bound to the current turn.

That is its purpose. Instead of reaching for global functions or shared handles, an actor does everything through the context it was handed: read ctx.message, reply to ctx.sender, and send, ask, pipe, spawn children, watch peers, switch behavior, stash, forward, or mark the message unhandled. Because those methods run through the context, each is automatically attributed to this actor and serialized with its message processing.

It describes one delivery:

MemberMeaning
messageThe payload. Typed as unknown; narrow it with instanceof.
selfThis actor's PID.
senderWho sent it. When the send used system.noSender(), ctx.sender === system.noSender().
actorSystem()The hosting system.

A context is valid only for the current receive call. The runtime recycles it once the behavior returns, so never store one on the actor or capture it in a callback that runs later; copy the values you need instead. Its action methods throw if called on a detached context (one not attached to a receiving actor), and you never construct one yourself: the runtime hands you the context with each message.

Contrast it with Context, the lighter object passed to preStart and postStop: that one describes the actor outside of any message and carries no message, sender, or send methods.

Tell

Fire and forget. The sender is recorded; the receiver sees it as ctx.sender.

From a PID, tell returns the outcome instead of throwing:

ts
const err = sender.tell(target, message);
// null = accepted
// ErrDead = target not running
// ErrMailboxFull / ErrMailboxDisposed = mailbox rejected it

From receive, ctx.tell(to, message) throws that same error.

A rejected user message is also published as a dead letter.

Cross-isolate tell reports transport accept, not mailbox accept. Posting the envelope returns null. A full or missing mailbox on the far side becomes a dead letter there. Encode or clone failures return their error immediately. See Multi-core. The same contract holds across machines: a tell to a remote PID reports what the network transport accepted, and an undeliverable envelope becomes a dead letter on the node that discovered it. See Remoting.

Ask

Send a message and wait for ctx.response(value) on the receiving side.

ts
const total = await outside.ask(greeter, new HowMany(), 1_000);

The first response wins; later calls are ignored. response is a no-op when the message was not delivered by ask.

WARNING

Do not ask an actor that is processing this call. It cannot reply until the current message finishes. Asking self from receive never completes. For call cycles, use request.

timeout is a duration in milliseconds. A non-positive or omitted value falls back to the system's askTimeout, so an ask is never unbounded. The wait is a lower bound with coarse expiry: an unanswered ask is rejected between one and two timeout periods, so the send path never reads the clock.

FailureWhen
ErrDeadTarget not running.
ErrRequestTimeoutNo reply in time.
mailbox errorDelivery rejected (ErrMailboxFull, …).

ctx.ask forwards to PID.ask and rejects with the same errors.

An ask to a remote PID crosses the network and settles with the same failures, sentinel identity preserved; see Remoting.

Request

Non-parking ask. Requires the actor to be spawned with reentrancy. Returns a RequestCall immediately; register onReply. The continuation runs on this actor's own turn.

ts
ctx.request(peer, new Get(), { timeout: 1_000 }).onReply((reply, error) => {
  // serialized with this actor's messages
});

PipeTo

pipeTo runs asynchronous work off the actor's message loop and delivers its result back as an ordinary message, so the actor never parks while a database read or HTTP call is in flight. It has its own page: PipeTo.

Forward

ctx.forward(to) sends the current message to to and keeps the original sender. The next behavior sees ctx.sender as whoever sent the message here, not this actor.

The preserved sender survives any boundary: forwarding to an actor on another isolate or another node carries the origin along, so the receiver can reply straight to it, wherever it lives.

Unhandled

ctx.unhandled() routes the current message to dead letters with reason ErrUnhandled and continues normally. Use this when unknown messages are expected. Throwing engages supervision.

Shutdown

ctx.shutdown() begins a graceful stop of the receiving actor.

WARNING

Do not await ctx.shutdown() from receive. Shutdown waits for the receive loop to go idle, so awaiting it from inside that loop never completes.

ts
ctx.shutdown();

From outside, await pid.shutdown().

System messages

Handle these with instanceof alongside your own classes.

MessageDelivered to behavior?Meaning
PostStartYesFirst message after start.
TerminatedYesAn actor this one watched has stopped. Carries actorPath: string.
PanicSignalYesEscalated failure. reason is the error. The sender is the failing actor.
PoisonPillNoInstructs a graceful stop. Travels through the mailbox so everything ahead of it runs first. The runtime consumes it; it is never passed to receive. Send with tell(pid, new PoisonPill()).
DeadletterNo (event)Published on the event stream, not delivered to the original receiver.

Messages already accepted behind a PoisonPill still drain before the actor stops. Sends arriving after the pill is consumed are rejected.

Compare send APIs

tellaskrequestpipeTo
Waits for a replyNoYes (parks)No (continuation)No (task result becomes a message)
Receiver replies withctx.responsectx.response
Safe in call cyclesYesNoYes, with allowAllYes
From code that is not receivea PID's tella PID's askNo. Needs a reentrant actor as the issuera PID's pipeTo

Released under the MIT License.