Mailboxes
Each actor has exactly one mailbox — a FIFO queue of envelopes
waiting to be processed. When you call ref.tell(message), the
framework wraps message 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 an unbounded FIFO mailbox. Nothing is ever discarded on the way in; the queue grows until the actor drains it, and the heap is the only ceiling.
Why unbounded? Because the alternative is worse in a way that is easy
to miss. Between v0.10 and v0.15 the default was bounded — 10 000
messages, drop-head — and the framework silently discarded the
oldest queued message whenever an actor fell behind. The trade was
supposed to be “lose some messages, gain a memory ceiling”, and it did
not hold up:
-
The ceiling was not real. Only the user queue was bounded. System messages — lifecycle, supervision, watch — were never capped, so a node could still exhaust its heap.
-
A mailbox cannot tell what it is discarding.
drop-headsuits telemetry, where the freshest reading is the only one that matters. It does not suit a delivery confirmation or a WebSocketclose— and those went through the same queue. Each of those turned into a filed defect.Two messages the framework sends on your behalf are now out of every policy’s reach: the death-watch
Terminated, and the command that spawns a WebSocket connection’s actor. Both take a lane no bound can shed (see Death watch and A mailbox of your own below). The test they pass is that the framework built them, sent them once, and kept nothing it could re-send. The rest is still application traffic, and only you know which of it is safe to lose.
So the loss is now something you ask for, per actor, and the growth is
something you are told about: an actor whose queue reaches 10 000
messages logs a warning, and again at each doubling. With metrics
enabled, actor_mailbox_size reports the depth per actor above that
mark, and actor_mailbox_depth reports the whole distribution below it —
so a burst that never reaches the warning threshold is still visible.
A queue with no ceiling has to stay cheap when it is deep, so both of a
mailbox’s queues are backed by a RingBuffer
rather than a plain array: taking the next message advances an index instead
of reindexing everything behind it, so the cost of delivering one message
does not grow with the backlog waiting behind it. This is invisible from
the outside — including to a Mailbox subclass of your own, which sees only
the methods.
When to bound a mailbox
Section titled “When to bound a mailbox”Reach for a bound when shedding load is better than absorbing it, and you can say which messages are safe to lose:
- A telemetry or sensor sink where only the newest reading matters.
drop-headkeeps the queue fresh. - An admission point in front of an expensive pipeline, where you
would rather refuse work than queue it.
drop-newkeeps what you already accepted,rejecttells the sender to back off. - Any actor exposed to a producer you do not control, where an unbounded queue is a denial-of-service surface. Read the third bullet with the first two: it says where a bound is worth considering, not that the traffic there is expendable. A WebSocket hub is the clearest case of both at once — see Bounding the hub’s mailbox for what a bound there does and does not cost.
import { ActorOptions } from 'actor-ts';
const sensorOptions = ActorOptions.create() .withMailboxCapacity(10_000) .withMailboxOverflow('drop-head');
system.spawn(SensorSink, 'sensors', sensorOptions);Setting a capacity is what creates the bound; withMailboxOverflow
decides which message is lost, and defaults to drop-head. Setting a
policy without a capacity is rejected rather than silently ignored — an
unbounded mailbox never overflows, so it would have nothing to do.
The three policies and how to choose between them are covered under
BoundedMailbox below — including why reject fails
the sender rather than the slow actor.
Drops from a capacity you set are counted by
actor_mailbox_dropped_total, labelled with the actor’s class and the
policy that fired. Per class, not per actor: shedding is what a bounded
mailbox is for, so a per-actor label would mint a permanent metric
series for every actor behaving exactly as designed. To see which
instance is shedding, add an onDrop of your own — it runs alongside the
stock counter rather than replacing it, and it is handed the envelope that
was discarded, so it can say what was lost and not only that something
was.
To keep that envelope rather than inspect it in passing, build the mailbox
with deadLetterDrops: true and each dropped message becomes a
dead letter with its sender intact. It is
off by default, and only reachable from a mailbox you construct yourself:
the drop runs on the sender’s stack, so routing every shed envelope is
work charged to exactly the burst the bound was meant to absorb cheaply.
See mailbox sizing.
Bringing your own mailbox
Section titled “Bringing your own mailbox”withMailbox replaces the queue outright — for a PriorityMailbox, or
a BoundedMailbox configured beyond what the two options above express,
or a subclass of your own:
import { ActorOptions, PriorityMailbox } from 'actor-ts';
const triageOptions = ActorOptions.create() .withMailbox(() => new PriorityMailbox({ priorityFor: (m) => m.urgency }));
system.spawn(Triage, 'triage', triageOptions);The mailbox is yours, but its drops are still counted: the cell registers
an observer on whatever you return, provided it implements
DropReportingMailbox — BoundedMailbox does, and a Mailbox subclass of
your own can by adding one method:
import { Mailbox, type Envelope, type MailboxDropObserver } from 'actor-ts';
class SheddingMailbox<T> extends Mailbox<T> { private readonly observers: Array<MailboxDropObserver<T>> = []; readonly deadLetterDrops = true;
observeDrops(observer: MailboxDropObserver<T>): void { this.observers.push(observer); }
override enqueue(envelope: Envelope<T>): void { if (this.shouldShed()) { for (const observer of this.observers) observer('drop-new', envelope); return; } super.enqueue(envelope); }}A report carries the envelope as well as the reason, and deadLetterDrops
says whether the cell should turn each one into a
dead letter. Hand over the envelope you
actually discarded — under a drop-head-shaped policy that is the one you
evicted, not the one that arrived, or every letter will name a message that
is still queued. Both are optional: omit the field and the drop is counted
and nothing more, which is what BoundedMailbox does unless you ask
otherwise.
Registration is additive, so a BoundedMailboxOptions.onDrop of your own
keeps firing alongside the stock counter. What you cannot do is combine
withMailbox with withMailboxCapacity — that is a configuration error
rather than a silent precedence rule.
One obligation: enqueueSignal
Section titled “One obligation: enqueueSignal”A queue that sheds has a short list of things it must not shed. The
framework announces a death exactly once — see
Death watch — and hands a WebSocket hub the
command that spawns a connection’s actor exactly once, so both arrive
through enqueueSignal rather than enqueue, and the contract is
queue it whatever your bound says:
class SheddingMailbox<T> extends Mailbox<T> { // ...as above...
/** Past the bound, deliberately: this one cannot be re-sent. */ override enqueueSignal(envelope: Envelope<T>): void { super.enqueue(envelope); }}The base implementation delegates to your enqueue, which is correct for
a queue that discards nothing and wrong for one that does — so override
it whenever you override enqueue to shed. BoundedMailbox and
PriorityMailbox already do. Delegating rather than writing straight to
the base queue is deliberate: a subclass may keep its messages somewhere
else entirely, and an envelope hidden in a store your dequeueUser never
reads is worse than one you dropped.
The envelope also carries undroppable: true, which removeOldest reads
— so a drop-head-shaped bound built on that seam steps over it for
free, and does not have to know why. If your enqueue throws instead,
the framework turns the refusal into a
dead letter and the dying actor’s teardown
continues; what it cannot do is invent a second copy of the
notification.
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()does not jump the queue — under the hood it’s a user message (PoisonPill), so the actor first drains the user messages already queued ahead of it, then stops (a graceful drain-then-stop). Only framework-emitted system messages get the absolute precedence above. - A failing actor’s supervisor decision (Restart / Resume / Stop) takes effect immediately, not after the queue clears.
- A death-watch
Terminatedis a user message too, for the same reasonPoisonPillis: it must arrive after everything already told to the watcher, so a handler that reacts to a death sees the work that preceded it. Undroppable is not the same as queue-jumping — it keeps its place in line and merely cannot be discarded from it.
You don’t normally see this distinction — system messages are emitted by the framework, not by your code. But understanding it explains why “supervision reacts instantly.”
BoundedMailbox
Section titled “BoundedMailbox”import { ActorOptions, Actor, ActorSystem, BoundedMailbox } from 'actor-ts';
class SlowConsumer extends Actor<{ kind: 'work'; n: number }> { override async onReceive(message: { kind: 'work'; n: number }): Promise<void> { await new Promise(r => setTimeout(r, 100)); // simulate slow work this.log.info(`processed ${message.n}`); }}
const system = ActorSystem.create('demo');
const consumerOptions = ActorOptions.create() .withMailbox(() => new BoundedMailbox({ capacity: 1_000, overflow: 'drop-head' }));
const consumer = system.spawn(SlowConsumer, 'consumer', consumerOptions);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' | Throw MailboxFullError at the tell site. Caller surfaces the backpressure. |
Which one you get when you name none depends on which door you came
through, and the difference is deliberate: constructing a
BoundedMailbox yourself defaults to reject, because you built a
bound and nothing else can be assumed about what is safe to lose.
withMailboxOverflow defaults to drop-head, the policy that suits
the workloads people actually reach for a bound to protect. Name the
policy either way and the question does not arise.
The options are validated at construction: a missing or non-positive
capacity and an unknown overflow policy throw OptionsError.
capacity bounds the messages the mailbox is allowed to discard, which
in a mailbox holding nothing but undelivered death notifications means the
queue sits above it rather than losing one. The overshoot is bounded by
how many actors this one watches, and it is the price of the exemption
Death watch describes: size a capacity for
your traffic, not to the byte.
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 { ActorOptions, Actor, ActorSystem, PriorityMailbox } from 'actor-ts';
type Message = | { readonly kind: 'urgent'; readonly text: string } | { readonly kind: 'normal'; readonly text: string } | { readonly kind: 'bulk'; readonly text: string };
class Worker extends Actor<Message> { override onReceive(message: Message): void { this.log.info(`[${message.kind}] ${message.text}`); }}
const workerOptions = ActorOptions.create<Message>() .withMailbox(() => new PriorityMailbox<Message>({ priorityFor: (message) => message.kind === 'urgent' ? 0 : message.kind === 'normal' ? 5 : 10, }));
const worker = system.spawn(Worker, 'worker', workerOptions);
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.
When priorityFor cannot answer
Section titled “When priorityFor cannot answer”Your callback does not have to be total. If it throws, or if it
answers with something that is not a usable number — undefined, NaN,
a value of another type — the message is queued at the lowest
priority and nothing else happens. It is not dropped and the failure
does not surface at the tell site.
Both halves matter:
- Lowest, not highest. A priority that could not be determined must not outrank one that was stated. Ties still break by FIFO, so unrankable messages stay in send-order relative to each other.
- Contained, not thrown.
priorityForruns synchronously inside the sender’stell. A sender is a bystander to the receiver’s mailbox configuration: it has nothing to do about a brokenpriorityForand no way to tell that from a failure of its own. An escaping throw landed in the sender’sonReceiveand got the sender restarted — the same confusion therejectcaution above describes, for a bug that has nothing to do with the sender at all.
±Infinity is a ranking, not a failure: -Infinity means “ahead of
everything”, +Infinity means “behind everything”, and both are ordered
as written. Only NaN and non-numbers count as undeterminable.
To see a contained failure rather than only its consequences, pass
onPriorityError. Nothing else in the system reports one — the message
still arrives, just last:
import { PriorityMailbox, PriorityMailboxOptions } from 'actor-ts';
const triageOptions = PriorityMailboxOptions.create<Message>() .withPriorityFor((message) => message.kind === 'urgent' ? 0 : 10) .withOnPriorityError((cause, message) => { log.warn(`priorityFor could not rank ${JSON.stringify(message)}: ${String(cause)}`); });
const mailbox = new PriorityMailbox<Message>(triageOptions);cause is whatever priorityFor threw, or a TypeError describing the
value it returned. Record and return from the hook: it runs on the
sender’s stack too, so a throw from inside it puts the escape back — and
costs the message its place in the queue.
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).
Bounding a priority mailbox
Section titled “Bounding a priority mailbox”A priority mailbox is unbounded unless you give it a capacity, exactly
like every other mailbox. The bound lives on the mailbox’s own options
rather than on ActorOptions, because withMailbox and
withMailboxCapacity cannot be combined — a supplied mailbox brings its
own bound:
import { ActorOptions, PriorityMailbox, PriorityMailboxOptions } from 'actor-ts';
const triageOptions = PriorityMailboxOptions.create<Message>() .withPriorityFor((message) => message.kind === 'urgent' ? 0 : 10) .withCapacity(10_000) .withOverflow('drop-lowest-priority');
const workerOptions = ActorOptions.create<Message>() .withMailbox(() => new PriorityMailbox<Message>(triageOptions));The policies are 'drop-lowest-priority', 'drop-new' and 'reject', and
reject is what you get when you name a capacity without one — naming a
capacity says how much you will hold, not what you are willing to lose.
| Policy | What happens on overflow |
|---|---|
'drop-lowest-priority' | Discard the message furthest from delivery — the lowest-priority one, and among equals the one that arrived last. The arriving message competes on the same terms, so a message ranked below the whole backlog is the one that goes. |
'drop-new' | Discard the incoming message, whatever priority it was given. |
'reject' | Throw MailboxFullError at the tell site. |
drop-head is deliberately not offered here. On a FIFO queue it means
“discard the stalest”, which works because arrival order is the only order
there is. On a priority queue the head is the message your priorityFor
called most important — dropping it would defeat the reason you chose this
mailbox. Drops are still reported as drop-head on
actor_mailbox_dropped_total (that label has a closed two-value
vocabulary), except when the discarded message was the arriving one, which
reports drop-new.
The options are validated at construction: a missing or non-callable
priorityFor, a non-positive capacity, an unknown policy, or a policy
without a capacity all throw OptionsError. What the callback returns
is a runtime question and is handled at the enqueue instead — see
When priorityFor cannot answer. On a
bounded mailbox the two meet: an unrankable message is the least important
thing in the queue, so under drop-lowest-priority it is the arrival that
gets shed, reported as drop-new.
Per-actor mailbox via ActorOptions
Section titled “Per-actor mailbox via ActorOptions”Three knobs on ActorOptions:
import { ActorOptions, PriorityMailbox } from 'actor-ts';
// Bound the default FIFO, and say what a full one discards.const cappedOptions = ActorOptions.create() .withMailboxCapacity(500) .withMailboxOverflow('drop-new');
// Full custom factory — pick the type and configure it.const customOptions = ActorOptions.create() .withMailbox(() => new PriorityMailbox({ priorityFor: (m) => m.urgency }));withMailboxCapacity(n) turns the default FIFO into a bounded one;
withMailboxOverflow picks the policy, defaulting to drop-head. The
policy on its own is rejected — an unbounded mailbox never overflows, so
it would be a no-op that reads like configuration.
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. It replaces the queue
rather than configuring it, so combining it with withMailboxCapacity
is a configuration error rather than a silent precedence rule, and the
framework wires no drop telemetry into whatever you return.
There is no system-wide mailbox setting: the default is unbounded and every bound is chosen per actor.
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.
A bound applies to the replay. Whichever bounded mailbox you
chose, unstashAll() is subject to the capacity and the overflow
policy you set — a replay is traffic re-entering the queue, not a
back door around it. What differs is only where the room comes
from: PriorityMailbox re-ranks, and BoundedMailbox puts the
replay at the head and sheds at the tail, so a full queue loses its
newest messages rather than the ones you deliberately parked. Under
reject nothing is lost at all — unstashAll() throws
MailboxFullError, the whole batch stays stashed, and the failure
reaches your actor’s supervisor like any other. Size the capacity
for the backlog plus whatever you expect to stash, or accept the
drops as the price of the bound.
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 whose queue will grow without limit if it falls behind
— an audit-log subscriber during a spike, or anything fed by a
producer you do not control. Bound it at a number that matches your
memory budget;
drop-headif the latest events are most valuable,drop-newif the ones you already accepted are. Watchactor_mailbox_wait_secondsto find out whether you need this before you need it — it climbs as soon as an actor stops keeping up, whereactor_mailbox_sizeonly reports once a queue passes 10 000 messages.actor_mailbox_depth’s p99 is what to pick the number itself from: a capacity is a bet on the worst-case burst, and that histogram is the measurement of it.
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.
