Problem
BoundedMailbox.enqueue dispatches its overflow policy through match(this.overflow) with three .with() arms and an .exhaustive(). That expression is evaluated per dropped message: a fresh ts-pattern matcher object, three arrow closures (each capturing this and env), and a walk of the arm list — all constructed before any of them is selected, and all garbage immediately after.
The path this sits on is the one that runs when the system is already saturated. A mailbox at capacity is, by definition, a producer outrunning a consumer; the drop path is what the framework does instead of keeping up, and making it the most expensive branch in the class is backwards. Under a burst every excess message pays the matcher, so the cost scales with exactly the thing that is already going wrong.
The value being matched is a three-member string union stored in a readonly field, set once in the constructor. It never changes, it is not a message, and it carries no payload — so this is not a case AGENTS.md's match-per-arm rule is aimed at (that rule is about incoming messages, events and commands; a policy field read in a hot loop is precisely the "computes a value / internal state" exemption). The repo already made this call once, explicitly, in its own benchmark suite.
Evidence
The call site:
src/mailbox/BoundedMailbox.ts:40-65
override enqueue(env: Envelope<T>): void {
if (this.size >= this.capacity) {
match(this.overflow)
.with('drop-head', () => {
// `removeOldest` rather than `dequeueUser`: the latter returns
// undefined while the mailbox is suspended, which used to make this
// whole arm a no-op — the queue grew past capacity and the drop was
// reported anyway. Counting is gated on an actual removal so the
// metric cannot claim a drop that did not happen.
const dropped = super.removeOldest();
if (dropped !== undefined) {
this.droppedCount++;
this.onDrop?.('drop-head');
}
super.enqueue(env);
})
.with('drop-new', () => {
this.droppedCount++;
this.onDrop?.('drop-new');
})
.with('reject', () => { throw new MailboxFullError(this.capacity); })
.exhaustive();
return;
}
super.enqueue(env);
}
The field it matches on — assigned once, never reassigned:
src/mailbox/BoundedMailbox.ts:24-38
export class BoundedMailbox<T = unknown> extends Mailbox<T> {
private readonly capacity: number;
private readonly overflow: BoundedMailboxOverflow;
private readonly onDrop?: (reason: 'drop-head' | 'drop-new') => void;
/** Number of messages dropped by the overflow policy — useful for metrics. */
droppedCount = 0;
constructor(options: BoundedMailboxOptions) {
super();
const settings = { ...(options as Partial<BoundedMailboxOptionsType>) };
new BoundedMailboxOptionsValidator().validate(settings);
this.capacity = settings.capacity!;
this.overflow = settings.overflow ?? 'reject';
this.onDrop = settings.onDrop;
}
The project's own measurement of the same library on a per-message path, and the exemption it already took:
benchmarks/single-node/become-unbecome.ts:11-19
/*
* The dispatch below deliberately stays a raw `if`-chain, against the
* project-wide `match()` rule (AGENTS.md). This benchmark measures the
* per-message path itself, and ts-pattern's allocation per `match()` call
* shows up directly in the number: converting it cost ~10 % here
* (57k -> 51k swap/s), consistently across alternating runs.
* Measuring the framework's overhead through a matcher that production
* actor code would amortise differently makes the figure say less, not more.
*/
This is the default mailbox for every actor since #310, so the drop path is not a niche configuration:
src/util/Constants.ts:93-93
export const DEFAULT_MAILBOX_CAPACITY = 10_000;
src/util/Constants.ts:105-105
export const DEFAULT_MAILBOX_OVERFLOW = 'drop-head' as const;
Proposal
- Replace the
match with a switch (this.overflow) over the same three literals and default: unreachable, or hoist the decision out of the hot path entirely: resolve the policy once in the constructor into a private onOverflow method reference (this.onOverflow = this.dropHead) and call it. The second shape keeps each arm a named private method, which reads better than the inline arrow bodies do today and is closer to the spirit of the per-arm rule than the current code is.
- Note the exemption explicitly in a comment, the way
become-unbecome.ts does, so the next sweep does not convert it back.
- The
drop-new arm is where the ratio is worst, because the arm body itself is two statements — there the matcher is the work.
- Consider the same treatment for any other
match that sits on a per-message path; this one is called out because it is the default mailbox and the saturated path.
Acceptance sketch
Adjacent issues: #408 (ring-buffer backing store to remove Array.shift()) touches the same method's drop-head arm — removeOldest() is the shift() in question, and it dominates that arm's cost today, so the two fixes compound rather than overlap. #773 (dropped messages are never dead-lettered) and #772 (prependUser bypasses the capacity check) are correctness findings in the same class. #647 (bounded PriorityMailbox) and #862 (default-mailbox config block) are the same default from other angles.
Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: reproduced by execution — with the caveat that the timing half is indicative, since other work was running on the machine. The real BoundedMailbox was driven against a local subclass with identical semantics dispatched by switch, both pre-filled to capacity and warmed, best of three alternating rounds:
overflow='drop-new' 2.000.000 overflowing enqueues ts-pattern 486 ms vs switch 25 ms -> 19.37x
overflow='drop-head' 2.000.000 overflowing enqueues ts-pattern 491 ms vs switch 161 ms -> 3.05x
drop-new isolates the matcher (the arm body is two statements) — ~243 ns versus ~12 ns per dropped message. drop-head shows a smaller ratio because removeOldest()'s Array.shift() dominates there, which is #408's subject.
The structural half needs no measurement and is machine-independent: one matcher object, three arrow closures and one .exhaustive() walk are constructed per call by the language's evaluation rules, regardless of which arm wins.
Part of the production-readiness review batch — tracked in #913.
Problem
BoundedMailbox.enqueuedispatches its overflow policy throughmatch(this.overflow)with three.with()arms and an.exhaustive(). That expression is evaluated per dropped message: a fresh ts-pattern matcher object, three arrow closures (each capturingthisandenv), and a walk of the arm list — all constructed before any of them is selected, and all garbage immediately after.The path this sits on is the one that runs when the system is already saturated. A mailbox at capacity is, by definition, a producer outrunning a consumer; the drop path is what the framework does instead of keeping up, and making it the most expensive branch in the class is backwards. Under a burst every excess message pays the matcher, so the cost scales with exactly the thing that is already going wrong.
The value being matched is a three-member string union stored in a
readonlyfield, set once in the constructor. It never changes, it is not a message, and it carries no payload — so this is not a case AGENTS.md'smatch-per-arm rule is aimed at (that rule is about incoming messages, events and commands; a policy field read in a hot loop is precisely the "computes a value / internal state" exemption). The repo already made this call once, explicitly, in its own benchmark suite.Evidence
The call site:
The field it matches on — assigned once, never reassigned:
The project's own measurement of the same library on a per-message path, and the exemption it already took:
This is the default mailbox for every actor since #310, so the drop path is not a niche configuration:
Proposal
matchwith aswitch (this.overflow)over the same three literals anddefault:unreachable, or hoist the decision out of the hot path entirely: resolve the policy once in the constructor into a privateonOverflowmethod reference (this.onOverflow = this.dropHead) and call it. The second shape keeps each arm a named private method, which reads better than the inline arrow bodies do today and is closer to the spirit of the per-arm rule than the current code is.become-unbecome.tsdoes, so the next sweep does not convert it back.drop-newarm is where the ratio is worst, because the arm body itself is two statements — there the matcher is the work.matchthat sits on a per-message path; this one is called out because it is the default mailbox and the saturated path.Acceptance sketch
BoundedMailbox.enqueueperforms no per-message matcher or closure allocation on the overflow path.drop-headstill gates its counter on an actual removal,drop-newstill counts and returns,rejectstill throwsMailboxFullError; existingMailboxVariantstests stay green.Adjacent issues: #408 (ring-buffer backing store to remove
Array.shift()) touches the same method'sdrop-headarm —removeOldest()is theshift()in question, and it dominates that arm's cost today, so the two fixes compound rather than overlap. #773 (dropped messages are never dead-lettered) and #772 (prependUserbypasses the capacity check) are correctness findings in the same class. #647 (boundedPriorityMailbox) and #862 (default-mailbox config block) are the same default from other angles.Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (
v0.13.0) and re-verified before filing: reproduced by execution — with the caveat that the timing half is indicative, since other work was running on the machine. The realBoundedMailboxwas driven against a local subclass with identical semantics dispatched byswitch, both pre-filled to capacity and warmed, best of three alternating rounds:drop-newisolates the matcher (the arm body is two statements) — ~243 ns versus ~12 ns per dropped message.drop-headshows a smaller ratio becauseremoveOldest()'sArray.shift()dominates there, which is #408's subject.The structural half needs no measurement and is machine-independent: one matcher object, three arrow closures and one
.exhaustive()walk are constructed per call by the language's evaluation rules, regardless of which arm wins.Part of the production-readiness review batch — tracked in #913.