Dispatchers
A dispatcher schedules the execution of actor message-processing
units. Whenever an actor’s mailbox has a message ready to process,
the dispatcher decides when the runtime pulls it and runs
onReceive.
JavaScript is single-threaded, but it has two interleaving primitives
for “do this later”: the microtask queue (queueMicrotask,
Promise.then) and the macrotask queue (setImmediate,
setTimeout(0)). Picking between them is the dispatcher’s job, and
the choice has real consequences for throughput, fairness, and
latency.
The four built-in dispatchers
Section titled “The four built-in dispatchers”The framework ships four, exposed as classes you instantiate and
pass to ActorSystem.create:
| Dispatcher | Schedules via | Trade-off |
|---|---|---|
HybridDispatcher (default) | queueMicrotask, and every 64th unit setImmediate | Microtask speed with a bounded escape, so timers and I/O still get a turn. |
MicrotaskDispatcher | queueMicrotask | Fastest. Starves I/O and timers under sustained actor load — see the warning below. |
ImmediateDispatcher | setImmediate or setTimeout(0) | The previous default. Lets I/O and timers interleave between every actor turn, at roughly 2.4 µs a hop. |
ThroughputDispatcher | setImmediate with a configurable run-N-then-yield budget | Like ImmediateDispatcher but drains up to throughput queued actor turns back-to-back before yielding. Balances throughput against fairness across actors. |
Why the default is a hybrid
Section titled “Why the default is a hybrid”The two obvious choices are each wrong half the time, and which half depends on a property of your workload you do not control.
setImmediate costs about 2.4 µs. An actor flooded with messages
amortises that across the batch it handles per turn, so the cost
disappears. An actor answering one request at a time cannot amortise
anything — its mailbox is empty between messages, so it pays the full
hop per message. Measured on a 10 000-exchange request/response
volley, that was 8.1 µs per round trip, of which roughly 4.8 µs was
the two scheduling hops. The work was the smaller half.
queueMicrotask removes that cost — the same volley measured almost
four times faster — and is unusable on its own, because microtasks
drain completely before the event loop reaches timers or I/O. Two
actors volleying re-queue a microtask from inside a microtask forever,
and nothing else in the process ever runs again.
The hybrid takes the speed and bounds the unfairness. It counts
consecutive units scheduled as microtasks, and on reaching 64 sends
one through setImmediate instead, which lets the loop advance before
the count starts over. A unit is one actor’s turn, itself up to
actor-ts.actor.throughput messages, so the loop gets a turn at least
every ~1 024 messages. The worst case is exactly what
ImmediateDispatcher always did — never anything new.
The count lives on the dispatcher rather than on each actor, and that is the load-bearing detail: the microtask chain is the union across every actor scheduled on it, so a per-actor budget would read 1 for each of two actors volleying forever, undercounting in precisely the case the budget exists to bound.
Picking a dispatcher
Section titled “Picking a dispatcher”import { ActorSystem, ActorSystemOptions, MicrotaskDispatcher } from 'actor-ts';
// Use microtasks: maximum throughput, minimum scheduling overhead.// Pick when you have no I/O at all OR I/O is so rare that starvation// isn't a concern.const actorSystemOptions = ActorSystemOptions.create().withDispatcher(new MicrotaskDispatcher());const system = ActorSystem.create('compute-heavy', actorSystemOptions);import { ActorSystem, ActorSystemOptions, ThroughputDispatcher } from 'actor-ts';
const actorSystemOptions = ActorSystemOptions.create().withDispatcher(new ThroughputDispatcher(50));const system = ActorSystem.create('mixed-workload', actorSystemOptions); // run up to 50 messages per actor before yielding to I/O. Default // is 16 in `ThroughputDispatcher`; bumping it to 50 wins on // throughput at the cost of HTTP-response latency.ThroughputDispatcher is the only one that holds a queue of its own for
scheduling purposes — the others hand each unit straight to the runtime.
(The hybrid keeps a short list too, but only while a yield is in flight, so
that units handed over during the yield are not overtaken by ones that
arrive after it.) That queue
is a RingBuffer for the same reason a
mailbox is: it is drained from the front throughput times per tick and it
holds every actor’s pending unit, so it is deepest exactly when the system
is busiest.
When the dispatcher choice matters
Section titled “When the dispatcher choice matters”For most apps, the default is fine. Three situations where it isn’t:
- Compute-heavy actor pipelines with no I/O. An ETL-style job
that reads from a journal, transforms in actors, writes to another
journal — no live HTTP requests, no broker callbacks. Raising the
hybrid’s budget (
Dispatchers.Hybrid(1_000)) trades the fairness you are not using for fewer yields. - Latency-sensitive HTTP servers. HTTP responses need to flush
promptly; if your actors monopolize the event loop, response
latency grows. The default already yields on a budget, but a
workload that is all actor work with hard latency targets can
yield on every turn with
ImmediateDispatcher, or drop to a smaller throughput budget. - Tests that need deterministic ordering. Microtasks complete
before the next macrotask, so
MicrotaskDispatcheris preferred for tests that expect “send N messages, observe all N effects” without a yield between them. See TestKit for the test-specific dispatcher.
Writing a custom dispatcher
Section titled “Writing a custom dispatcher”The Dispatcher interface is tiny:
interface Dispatcher { readonly id: string; execute(task: () => void | Promise<void>): void; onError?: (error: unknown, dispatcherId: string) => void;}Implement the first two and you have a custom dispatcher; onError
is optional and the framework fills it in (see
When a work unit throws). Common
reasons to write one:
- Tracing: wrap the work-unit to attach an OpenTelemetry span so every actor message gets its own trace context.
- Metering: count messages processed, report to a metrics collector.
- Per-priority isolation: keep a “fast lane” for system actors
while user actors run on a separate queue. (For most apps,
per-actor priorities via
PriorityMailboxare enough; per- dispatcher isolation is an advanced case.)
import type { Dispatcher } from 'actor-ts';
class TracingDispatcher implements Dispatcher { readonly id = 'tracing-dispatcher'; constructor(private readonly inner: Dispatcher) {} execute(task: () => void | Promise<void>): void { this.inner.execute(() => withTraceContext(task)); }}Wrap-and-delegate is the usual shape — keep the underlying scheduling behavior, just add cross-cutting concerns.
Per-actor dispatcher
Section titled “Per-actor dispatcher”The actor system has one default dispatcher. Individual actors
can specify their own via ActorOptions:
import { ActorOptions, MicrotaskDispatcher } from 'actor-ts';
const crunchyOptions = ActorOptions.create().withDispatcher(new MicrotaskDispatcher());
const fastActor = system.spawn(Crunchy, 'crunchy', crunchyOptions);Most apps don’t need per-actor dispatchers — the system-level one applies uniformly. Reach for it when you have a mixed workload where some actors need throughput while others need fairness with I/O on the same node.
When a work unit throws
Section titled “When a work unit throws”A throw out of your onReceive never reaches the dispatcher — it
goes to the parent’s supervision
strategy. What the dispatcher sees is the rarer thing: a failure in
the machinery around the handler, or in a task handed straight to
dispatcher.execute. Nothing supervises those, so the framework
reports them twice over:
- The system logger gets an
errorrecord — so the failure reaches every configured log sink along with everything else, MDC included. - The event stream gets a
DispatcherError, carrying the failing dispatcher’sid, thecause, and theActorRefwhose turn it was (nullfor work that belongs to no actor).
import { Actor, DispatcherError } from 'actor-ts';
class DispatcherAlarm extends Actor<DispatcherError> { override preStart(): void { this.system.eventStream.subscribe(this.self, DispatcherError); } override onReceive(event: DispatcherError): void { this.log.error(`dispatcher ${event.dispatcherId} lost a turn`, event.cause); }}This works for every dispatcher an actor runs on, including a per-actor instance and a third-party implementation the system never sees — the framework catches the failure at the actor cell, before the dispatcher is involved.
console.error remains only as the last resort, for a dispatcher
used outside an actor system: onError is unset until an
ActorSystem adopts the instance, and a failure that reaches nobody
is worse than one that reaches a terminal. A sink you set yourself
is never taken over — the system wires its own only into a free slot.
Where to next
Section titled “Where to next”- Mailboxes — the queue the dispatcher pulls from. FIFO / bounded / priority.
- Timers and scheduling — actor-bound timers; uses the scheduler, not the dispatcher.
- ActorSystem — passing a dispatcher via the settings argument.
- TestKit — the test-specific dispatcher that runs synchronously for assertion-friendly tests.
