Mailboxes
このコンテンツはまだ日本語訳がありません。
Each actor has exactly one mailbox — a FIFO queue of envelopes
waiting to be processed. When you call ref.tell(msg), the
framework wraps msg in an envelope (with sender, log-context, and
optional trace context) and enqueues it on the recipient’s mailbox.
The dispatcher pulls the next envelope, hands it to the actor’s
onReceive, and waits for that to finish before pulling the next
one.
That gives every actor the “one message at a time” guarantee — and the mailbox is the thing that makes that physically true.
The default mailbox
Section titled “The default mailbox”If you don’t configure anything, the actor gets a bounded FIFO
mailbox: capacity 10 000 messages, overflow policy drop-head.
A slow consumer that can’t keep up sheds its oldest queued
messages instead of growing without bound. The framework
won’t OOM you because of one runaway actor.
Why bounded by default? Unbounded was the pre-#310 shape and it’s a classic Akka anti-pattern in disguise: most production incidents that traced back to “the actor framework” turned out to be an unbounded mailbox absorbing more traffic than the actor could ever drain, until the heap ran out. 10 000 is high enough that a well-tuned actor never hits it on a normal traffic spike; if you DO hit it, the actor’s design is wrong (slow consumer, throughput mismatch) and the bound makes that visible operationally instead of as a midnight OOM.
drop-head is the right default policy for the common cases:
telemetry, sensor readings, status pings, watch events — workloads
where the freshest message is the only one that matters and stale
ones can be discarded. When you need different semantics
(drop-new / reject) per actor, override via
Props.withMailbox(...).
When to override the default
Section titled “When to override the default”- An actor that needs strict at-least-once delivery under
bursts. Either give it more capacity, switch to
drop-new(so the queue stays correct but admission is dropped), orreject(so the sender knows to back off). - An actor with deterministic replay requirements (event
sourcing, test setups). Use
Props.withMailbox(() => new Mailbox())to opt back into the unbounded shape — losing a message during replay is unacceptable. - An actor that handles a mix of urgent and routine messages, where you want the urgent ones to jump the line. Use a priority mailbox.
import { Mailbox } from 'actor-ts'; // the unbounded base mailbox
system.spawn( Props.create(() => new MyActor()) .withMailbox(() => new Mailbox()), // explicit opt-out from the bounded default);Capacity-only override
Section titled “Capacity-only override”If the bounded shape + drop-head are right but 10 000 is the
wrong number for one specific actor, set the capacity in one
line — the default factory honours it:
system.spawn( Props.create(() => new BurstyActor()) .withMailboxCapacity(100_000), // still bounded + drop-head, just deeper);System messages always come first
Section titled “System messages always come first”Inside every mailbox, two queues live side-by-side: user messages
(your tells) and system messages (lifecycle signals — create,
terminate, failure, watch, …). System messages have absolute
precedence: even if 10 000 user messages are queued, the next
stop signal or supervisor failure is processed before any of
them.
This matters because:
- Calling
ref.stop()on an actor with a long mailbox doesn’t wait for that mailbox to drain — theterminatesystem message jumps to the front, and the actor stops promptly. - A failing actor’s supervisor decision (Restart / Resume / Stop) takes effect immediately, not after the queue clears.
You don’t normally see this distinction — system messages are emitted by the framework, not by your code. But understanding it explains why “stop is fast” and “supervision reacts instantly.”
BoundedMailbox
Section titled “BoundedMailbox”import { Actor, ActorSystem, Props, BoundedMailbox } from 'actor-ts';
class SlowConsumer extends Actor<{ kind: 'work'; n: number }> { override async onReceive(msg: { kind: 'work'; n: number }): Promise<void> { await new Promise(r => setTimeout(r, 100)); // simulate slow work this.log.info(`processed ${msg.n}`); }}
const system = ActorSystem.create('demo');
const consumer = system.spawn( Props.create(() => new SlowConsumer()) .withMailbox(() => new BoundedMailbox({ capacity: 1_000, overflow: 'drop-head' })),);The mailbox here holds up to 1 000 user messages. When a 1 001st message arrives, the overflow policy decides what happens.
Three policies:
| Policy | What happens on overflow |
|---|---|
'drop-head' | Dequeue the oldest message in the queue, discard it, enqueue the new one. Newest messages always make it in. |
'drop-new' | Discard the incoming message. The old queue is preserved unchanged. |
'reject' (default) | Throw MailboxFullError at the tell site. Caller surfaces the backpressure. |
Picking between them is a backpressure-vs-loss trade-off:
drop-head= “freshest wins.” Right for telemetry, sensor data, status pings — where stale messages are worthless and the latest snapshot is the only thing that matters.drop-new= “first wins.” Right for command-streams where re-ordering is unacceptable and dropping a late arrival is OK.reject= “let the sender deal with it.” Right when the sender has a meaningful backoff response (retry, route to a different actor, return 503 from an HTTP handler).
droppedCount on the mailbox instance tracks how many messages
have been discarded — useful to wire into a metrics gauge so you
notice when the bound is hit.
PriorityMailbox
Section titled “PriorityMailbox”import { Actor, ActorSystem, Props, PriorityMailbox } from 'actor-ts';
type Msg = | { readonly kind: 'urgent'; readonly text: string } | { readonly kind: 'normal'; readonly text: string } | { readonly kind: 'bulk'; readonly text: string };
class Worker extends Actor<Msg> { override onReceive(msg: Msg): void { this.log.info(`[${msg.kind}] ${msg.text}`); }}
const worker = system.spawn( Props.create(() => new Worker()) .withMailbox(() => new PriorityMailbox<Msg>({ priorityFor: (msg) => msg.kind === 'urgent' ? 0 : msg.kind === 'normal' ? 5 : 10, })),);
worker.tell({ kind: 'bulk', text: 'batch import row 1' });worker.tell({ kind: 'normal', text: 'user login' });worker.tell({ kind: 'urgent', text: 'page-out: disk full' });// → processed order: urgent → normal → bulkThe priorityFor callback runs at enqueue time, computing a
numeric priority per message. Lower numbers go first (priority
0 is highest), and ties break by FIFO insertion order — so two
'normal' messages stay in send-order relative to each other.
Common shapes for priorityFor:
- Per-
kindconstant table — like the example above. Easy to read, easy to evolve. - Field-derived —
priorityFor: (m) => m.deadlineMsmakes earliest-deadline messages run first. Works because both axes are “lower = sooner.” - Caller-tagged — sender includes
priority: numberin the message andpriorityForjust reads it. Sometimes the right call; usually a smell that the recipient should derive priority from message content instead.
The current implementation uses a sorted-insertion array — O(log n) locate + O(n) splice on each enqueue. Fine for mailboxes that stay in the low thousands; if you have a sustained 10 000-message backlog where priority insertion shows up in profiles, the mailbox is open to a heap-backed swap (see the source).
Per-actor mailbox via Props
Section titled “Per-actor mailbox via Props”Two knobs on Props:
import { Props, BoundedMailbox } from 'actor-ts';
// Just bound the default FIFO — convenience helper.Props.create(() => new MyActor()).withMailboxCapacity(500);
// Full custom factory — pick the type and configure it.Props.create(() => new MyActor()) .withMailbox(() => new BoundedMailbox({ capacity: 500, overflow: 'drop-head' }));withMailboxCapacity(n) is shorthand for “the default FIFO, but
bounded with the default reject overflow.” Useful when you
just want a cap and don’t care about the policy details.
withMailbox(factory) is the general form — you return a brand-new
mailbox instance from the factory. The factory is called once per
actor instance (including on restart), so each restarted actor
gets a fresh empty mailbox-data-structure. System-level mailbox
defaults can also be configured via application.conf (see
Configuration).
Mailboxes + stash
Section titled “Mailboxes + stash”When an actor calls this.context.stash() inside onReceive, the
current message is parked. When the actor later calls
unstashAll(), the parked messages are re-prepended to the front
of the mailbox.
This works the same way for all three mailbox types — the framework
calls mailbox.prependUser(envs), and the mailbox decides how to
reinsert. Notably for PriorityMailbox, unstashed messages are
re-prioritized: a stashed bulk message rejoins the bulk tier,
even if you stashed it while urgent messages were arriving. Stash
order is preserved within a priority tier.
See Become and stash for the full behavior-switching story.
When the mailbox choice matters
Section titled “When the mailbox choice matters”For most actors, the default unbounded FIFO is right. Reach for an alternative in three situations:
- Producer/consumer mismatch. The producer can emit faster than the consumer can drain. Bound the consumer’s mailbox; pick an overflow policy that matches the workload (drop-head for telemetry, reject for HTTP-driven backpressure).
- Latency budget per kind. Some messages must be handled in
tens of milliseconds (user-facing requests), others can wait
minutes (background reconciliation). Priority mailbox; the
urgent kind gets
0, the background kind gets100. - Memory bound. An actor with no application-level priority
distinction, but whose mailbox could in theory grow without
limit (an audit-log subscriber that falls behind during a
spike). Bound it at a number that matches your memory budget;
drop-headif the latest events are most valuable,reject+ metric if the loss should be visible.
Where to next
Section titled “Where to next”- Dispatchers — the scheduler that pulls from the mailbox. Mailbox = the queue; dispatcher = when to drain it.
- Become and stash —
parking messages for later, restoring them via
unstashAll. - Actor —
onReceiveis what the mailbox delivers messages to. - Coordinated shutdown — what happens to pending mailbox messages during graceful shutdown.
The BoundedMailbox and
PriorityMailbox API
references cover the full settings shape.