Dispatcher tuning
Ce contenu n’est pas encore disponible dans votre langue.
The dispatcher decides when actor messages run on the JavaScript event loop. Three shapes ship:
| Dispatcher | Schedules via | Fits |
|---|---|---|
MicrotaskDispatcher | queueMicrotask | CPU-tight; no I/O. |
ImmediateDispatcher (default) | setImmediate / setTimeout(0) | HTTP servers + mixed I/O. |
ThroughputDispatcher | setImmediate with N-then-yield | Batch processing. |
The default — ImmediateDispatcher — is right for most apps.
This page covers when it isn’t, and how to pick a better fit.
Default behavior
Section titled “Default behavior”const system = ActorSystem.create('my-app');// ↑ uses ImmediateDispatcher by defaultActor messages run via setImmediate, which yields between
each message — letting I/O callbacks (HTTP handlers, broker
messages, timer fires) interleave naturally.
For HTTP servers + broker-actor clusters (the common case), this gives good HTTP latency at the cost of slightly higher per-message overhead.
Symptom: high HTTP latency under actor load
Section titled “Symptom: high HTTP latency under actor load”P99 HTTP response time is 200ms; actors are processing tensof thousands of messages/sec. The actor work isn't theproblem — it's that HTTP requests can't get a turn.Cause: an actor (or group of actors) is processing messages so fast that HTTP handlers wait for a turn.
Fix: stick with ImmediateDispatcher (the default) and
lower the busy actor’s own batch budget:
import { ActorOptions } from 'actor-ts';
const heavyOptions = ActorOptions.create().withThroughput(4);
const heavyActor = system.spawnAnonymous(HeavyWorker, heavyOptions);The heavy actor handles 4 messages, yields, lets HTTP catch up, handles 4 more. HTTP latency drops; throughput on the heavy actor falls off only as far as the smaller batch costs it.
Symptom: low actor throughput, low CPU usage
Section titled “Symptom: low actor throughput, low CPU usage”The actor system is doing 1000 msg/sec on an idle CPU.Profile shows time spent in setImmediate.Cause: ImmediateDispatcher has per-message overhead from
yielding to the event loop on every message. For tight-loop
CPU work without I/O, this is wasted.
Fix: use MicrotaskDispatcher:
import { ActorOptions, MicrotaskDispatcher } from 'actor-ts';
const cpuOptions = ActorOptions.create().withDispatcher(new MicrotaskDispatcher());
const cpuActor = system.spawnAnonymous(CpuIntensive, cpuOptions);Microtasks bypass the event loop, ~50× faster scheduling. Caveat: a CPU-tight actor on microtask can starve I/O (network reads, timers). Use only when:
- The actor doesn’t share the system with HTTP traffic (compute-only workers).
- The actor itself doesn’t
awaitI/O (purely CPU).
ThroughputDispatcher options
Section titled “ThroughputDispatcher options”new ThroughputDispatcher(100); // queued actor turns per tickThe constructor is positional — throughput first, an
optional dispatcher id second:
throughput— queued turns drained per tick, across every actor sharing this dispatcher. Not messages, and not per actor: each turn belongs to a different actor, because an actor may only have one turn queued at a time. Higher = more throughput, worse I/O interleaving. Common values: 10-1000. Defaults to 16.- Between batches it always yields via
setImmediate(falling back tosetTimeout(0)wheresetImmediateis unavailable), so I/O and timers interleave. This isn’t configurable.
For a batch processor with many actors handling broker messages:
throughput: 200 is a reasonable starting point.
Per-actor throughput
Section titled “Per-actor throughput”How many messages one actor handles per turn before it yields is a separate setting from anything on the dispatcher:
import { ActorOptions } from 'actor-ts';
const bulkOptions = ActorOptions.create().withThroughput(64);
const bulk = system.spawnAnonymous(BulkProcessor, bulkOptions);System-wide, the same knob is actor-ts.actor.throughput:
actor-ts { actor { throughput = 64 }}Precedence is the usual one — withThroughput() beats HOCON beats
the built-in default of 16.
This is the setting that removes scheduling overhead for a busy
actor. Every message used to cost a full setImmediate round
trip (~2.4 µs) regardless of any dispatcher setting; batching
amortises that across the whole batch, which is worth roughly
2-3× on a tell-driven workload.
The trade-off is fairness, and it is direct: a batch runs to its
budget without yielding, so budget × handler time is the delay
every timer, socket read and other actor can see. Lower it toward
1 for an actor with a slow handler that shares a system with
latency-sensitive work; raise it for a short-handler actor that is
a throughput bottleneck. A batch always ends early on an empty
mailbox, a stop or suspend, and a throttle bucket that runs out —
so the budget is a ceiling, never a commitment.
What a message costs when nothing is watching
Section titled “What a message costs when nothing is watching”Worth knowing before you tune anything, because it sets the scale everything else is measured against.
On the default dispatcher the scheduling round trip dominates
everything else on the message path — about 2.4 µs against a raw
mailbox operation of tens of nanoseconds. That is why
per-actor throughput is the setting with the
largest effect on a tell-driven workload, and why a faster queue or a
leaner handler moves the number far less than it feels like it should.
Below that sits the framework’s own per-message overhead with metrics and tracing off. It used to be four extension-registry lookups, four metric label objects built for a registry that discards them, a closure, a throwaway array, and two clock reads — per message, on every system, whether or not anything was observing. #411 removed all of it; what remains is a handful of field reads and null checks. Both extensions are still free to switch on at runtime, and a message is either wholly instrumented or wholly not — the handles are resolved once per message rather than at each instrumentation point.
If you are chasing allocation pressure rather than latency,
benchmarks/memory/receive-path.ts is the arm that isolates this, and
its header explains why the effect shows up as throughput rather than as
heap.
Per-actor dispatcher
Section titled “Per-actor dispatcher”import { ActorOptions, MicrotaskDispatcher } from 'actor-ts';
const computeOptions = ActorOptions.create().withDispatcher(new MicrotaskDispatcher());
const heavy = system.spawnAnonymous(BulkProcessor, computeOptions);
const httpHandler = system.spawn( HttpHandler, // → uses system's default ImmediateDispatcher);Mix freely: a compute-only actor can run on microtasks while HTTP handlers stay on the default.
What a per-actor dispatcher is not good for is batching. A
ThroughputDispatcher given to one actor drains a queue that never
holds more than that actor’s single pending turn, so its budget is
unreachable — use withThroughput()
instead. A ThroughputDispatcher earns its keep when a group of
actors shares it.
System-wide dispatcher
Section titled “System-wide dispatcher”const actorSystemOptions = ActorSystemOptions.create().withDispatcher(new ThroughputDispatcher(100));const system = ActorSystem.create('my-app', actorSystemOptions);Override the default for every actor that doesn’t specify otherwise. Useful for batch-only systems with no HTTP traffic.
Measuring
Section titled “Measuring”Two stock metrics split a message’s latency at the moment it is picked up:
actor_mailbox_wait_seconds — queued, waiting its turnactor_message_handler_seconds — inside onReceiveRead them together; the pair is what tells you whether tuning the dispatcher can help at all:
- Wait p99 high, handler p99 low — the actor is fine and messages are sitting in the queue. Dispatcher tuning helps: raise the per-actor throughput budget so each turn drains more of the backlog, or move the actor off a dispatcher it is sharing with slow work.
- Handler p99 high — the handler itself is the cost. No dispatcher setting makes it faster; that is a code or downstream problem, and raising the budget makes latency worse for everything sharing the runtime.
Queueing delay used to have to be inferred from the spread between p50 and p99 of the handler histogram, which conflated it with variance in the work itself. It is now measured directly.
The actor_mailbox_size gauge under load shows whether actors are
keeping up. Persistently growing depth = either a slow handler or a
misconfigured dispatcher. It is the later signal of the two — a
series only exists above 10 000 queued messages, where wait starts
climbing as soon as an actor falls behind. actor_mailbox_depth fills
that gap: a label-free histogram observed once per delivery, so a burst
that never reaches 10 000 still shows its tail.
Is the dispatcher itself the queue?
Section titled “Is the dispatcher itself the queue?”Both metrics above are about an actor’s mailbox. A message can also wait one level up — in the dispatcher, after the cell has asked for a turn and before that turn starts:
actor_dispatcher_queue_delay_seconds{dispatcher="..."}At rest this is a single scheduling hand-off, microseconds. When it climbs, the actor is not the problem and neither is its mailbox: turns are queued behind other turns, which is what dispatcher tuning is for.
histogram_quantile(0.99, rate(actor_dispatcher_queue_delay_seconds_bucket[5m]))# Per dispatcher. A high p99 on one and not the others tells you which# pool to split, and which actors to move off it.Reading it beside the mailbox pair completes the diagnosis:
- Delay p99 high — turns are competing. Either something on this
dispatcher is not yielding (a
ThroughputDispatcherwith too high a budget, or a handler that blocks), or too many actors share it. Split the dispatcher, or lower its throughput so it yields more often. - Delay p99 low, mailbox wait p99 high — the turns start promptly and
one actor’s own queue is the backlog. Raise that actor’s
withThroughputso each turn drains more, rather than touching the dispatcher.
There is deliberately no dispatcher_saturation_ratio — a 0–1 busy
fraction cannot be computed honestly on all three supported runtimes, and
Stock metrics
records the measurements behind that decision. MicrotaskDispatcher is
the one case where a low delay is not evidence of headroom, for the
reason noted there.
Heuristics
Section titled “Heuristics”HTTP server + actors → ImmediateDispatcher (default)Compute-heavy + no HTTP → MicrotaskDispatcherBatch processing, many actors → ThroughputDispatcher (throughput 100-500)One actor is the bottleneck → default dispatcher + withThroughput(64...256)Mixed: heavy actor + HTTP → default dispatcher + withThroughput(2...8) on the heavy actorWhere to next
Section titled “Where to next”- Dispatchers — the conceptual reference.
- Mailbox sizing — the complementary knob.
- Stock metrics — the per-actor performance metrics.
