Skip to content

Ask pattern

tell is fire-and-forget — it returns immediately and you never see the actor’s response. Most actor-to-actor communication is fine like that. But sometimes you need a reply: an HTTP handler that asks an actor for state and serializes it as a response, a saga that needs the result of step N before issuing step N+1, a test that wants to assert on a return value.

ask is the helper for those cases. It sends a message to an actor, attaches a temporary “reply-to” ref under the hood, and returns a Promise that resolves with the first reply or rejects on timeout.

import { Actor, ActorSystem, Props, ask, type ActorRef } from 'actor-ts';
type Cmd =
| { readonly kind: 'inc' }
| { readonly kind: 'get'; readonly replyTo: ActorRef<number> };
class Counter extends Actor<Cmd> {
private count = 0;
override onReceive(cmd: Cmd): void {
if (cmd.kind === 'inc') this.count++;
else if (cmd.kind === 'get') cmd.replyTo.tell(this.count);
}
}
const system = ActorSystem.create('demo');
const counter = system.actorOf(Props.create(() => new Counter()));
counter.tell({ kind: 'inc' });
counter.tell({ kind: 'inc' });
// Inside the request, `replyTo` is `this.sender` from the recipient's
// perspective. But you'd have to construct that yourself. `ask`
// wires it for you: builds a temporary ref, attaches it, returns a
// Promise.
const current = await ask<Cmd, number>(counter, {
kind: 'get',
replyTo: undefined as any, // ask sets this — see below
}, 5_000);
console.log(current); // 2
await system.terminate();

The signature:

function ask<TReq, TRes = unknown>(
target: ActorRef<TReq>,
message: TReq,
timeoutMs: number = 5_000,
): Promise<TRes>

Three parameters: the actor to ask, the message to send, and a timeout in milliseconds. Returns Promise<TRes> — resolves with the first reply, rejects with AskTimeoutError if no reply comes within the timeout.

ask works by sending your message with a temporary reply-to ref attached. The receiver replies by tellling that ref.

By convention, the framework uses replyTo as the field name in the request message, and the recipient pattern-matches on it the same as any other field. Look at the Cmd type above: the 'get' variant has replyTo: ActorRef<number>.

For the recipient, it’s just a regular tell on a regular ref:

if (cmd.kind === 'get') {
cmd.replyTo.tell(this.count);
}

Inside the recipient, you can also reach the same ref via this.sender — it’s an Option<ActorRef>, and ask attaches the temporary ref through that channel too:

if (cmd.kind === 'get') {
this.sender.forEach((replyTo) => replyTo.tell(this.count));
}

Either works. Using an explicit replyTo field makes the type contract more obvious (the request shape declares it expects a reply of type ActorRef<number>); using this.sender is more implicit but works for actors that handle both ask-style and fire-and-forget messages.

import { ask, AskTimeoutError } from 'actor-ts';
try {
const result = await ask(counter, { kind: 'get', replyTo: undefined as any }, 1_000);
console.log(result);
} catch (e) {
if (e instanceof AskTimeoutError) {
console.log('counter did not reply in time');
} else {
throw e;
}
}

The timeout is mandatory in spirit — defaulting to 5 seconds catches the case where you forgot to think about it. Tune per call site:

  • Cheap in-process replies: 100-500 ms.
  • Cross-cluster lookups: 1-5 s depending on network expectations.
  • Anything that might hit a slow downstream (DB query, HTTP call inside the actor): match the underlying-call’s timeout + a small buffer.

When the timeout fires, the temporary reply-to ref is stopped. A late reply from the actor goes to dead letters and the AskTimeoutError rejects the Promise. The actor itself isn’t affected — it has no idea the asker gave up.

A recipient can reject an ask by replying with an Error instance. The ask Promise rejects with that error rather than resolving with the message.

class Service extends Actor<Cmd> {
override onReceive(cmd: Cmd): void {
if (cmd.kind === 'fetch') {
if (this.unavailable) {
cmd.replyTo.tell(new Error('service unavailable'));
return;
}
cmd.replyTo.tell(this.data);
}
}
}
try {
const data = await ask(service, { kind: 'fetch', replyTo: undefined as any });
} catch (e) {
// e is the actor's "service unavailable" Error.
}

This is a deliberate convention — the framework recognizes Error-typed replies and rejects the Promise. Carry domain errors the normal way (typed via the reply union) for everything else; reserve the Error-reply path for genuine failure states.

ask is convenient but it has overhead — it allocates a temporary ref, registers it with the system, and tears it down on resolution/timeout. Three situations where plain tell is the right call instead:

The undefined as any placeholder above is ugly. A common refinement is to split the request type so the replyTo field is filled by ask, not by the caller:

type GetRequest = { readonly kind: 'get'; readonly replyTo: ActorRef<number> };
// Helper: caller provides everything EXCEPT replyTo; the ask helper
// (or a wrapper around `ask`) injects the reply-to ref.
type AskMessage<T extends { replyTo: ActorRef<unknown> }> = Omit<T, 'replyTo'>;
async function askCounter(ref: ActorRef<GetRequest>): Promise<number> {
// Internally, `ask` attaches the reply-to. This wrapper hides the
// placeholder.
return ask<GetRequest, number>(ref, { kind: 'get' } as GetRequest, 1_000);
}

For codebases with many ask-shaped messages, building a tiny wrapper per message type or a generic askTyped(ref, kind, fields) helper removes the boilerplate at the call sites.

  • Messages — the replyTo: ActorRef<T> field convention covered in detail.
  • Actorthis.sender and how the framework wires it.
  • ActorSystem — where temporary ask refs are registered and torn down.
  • CircuitBreaker — when the ask’s downstream call is flakey and you want to fail fast rather than time out.
  • Future patternspipeTo, after, sequence — composing async results back into the actor world without await-blocking.

The ask function API reference documents the full signature and error types.