Actor-Web Learning Guide · Chapter 1
Chapter 1: JavaScript Concurrency and Actor Mailboxes
The question
If JavaScript executes one stack at a time, why do we still need an actor concurrency model?
Because "one JavaScript stack" and "one owner of mutable state" are different guarantees.
The JavaScript runtime decides when callbacks receive execution time. An actor runtime decides which identity owns state, how messages wait for that identity, how one message is selected, and what happens when demand exceeds capacity. Actor-Web relies on JavaScript scheduling; it does not replace it.
Why this chapter comes first
Every later Actor-Web guarantee runs on top of a host scheduler. Before studying supervision, distribution, durable execution, or agent authorization, you need to be able to distinguish:
- work that is waiting from JavaScript that is executing
- concurrent operations from parallel JavaScript execution
- a host callback queue from an actor mailbox
- serialized state mutation from fair CPU scheduling
- asynchronous waiting from synchronous blocking
Without those distinctions, it is easy to attribute VM-level guarantees to an application-level actor library.
Outcomes
After this chapter, you can:
- predict the order of synchronous code, Promise reactions,
Node
process.nextTick(...), timers, andsetImmediate(...)in bounded examples - explain the purpose of the Node.js timers, pending-callbacks, poll, check, and close-callbacks phases
- explain why a timer threshold is not an execution deadline
- distinguish concurrency, cooperative scheduling, and CPU parallelism
- trace a local Actor-Web
send(...)through mailbox scheduling and handling - explain
drop,fail, andparkas different pressure decisions - state exactly what one-message-at-a-time handling protects
- explain why a long actor handler can still starve unrelated actors
Four layers, four different questions
Use this ladder instead of one overloaded "event loop" diagram:
| Layer | Owns | Question it answers |
|---|---|---|
| JavaScript execution | The current call stack and language jobs | What JavaScript is executing now? |
| Host scheduling | Timers, I/O readiness, phase queues, and task selection | Which callback can receive a turn next? |
| Actor-Web runtime | Actor identity, mailbox, routing, processing rounds, and pressure | Which message may this actor handle next? |
| Application behavior | Domain context, valid transitions, and effects | What should this message mean in the current state? |
The layers interact, but none substitutes for the layer above or below it.
Vocabulary
| Term | Meaning in this chapter | Important boundary |
|---|---|---|
| Call stack | The JavaScript frames executing synchronously now | A queued callback cannot interrupt it |
| Task or macrotask | A host-scheduled unit such as a script, timer callback, or
setImmediate callback |
Exact sources and ordering differ by host |
| Microtask | A language/runtime job such as a Promise reaction | Microtasks run before the host selects a later macrotask |
nextTick queue |
Node-specific callbacks processed after the current operation | It is not a libuv event-loop phase |
| Event-loop phase | A Node/libuv stage with a class of callbacks or I/O work | It is host scheduling, not actor scheduling |
| Concurrency | Multiple operations are in progress over overlapping time | It does not require simultaneous JavaScript execution |
| Parallelism | Work executes simultaneously on multiple CPU execution resources | Requires workers, processes, or another parallel runtime |
| Cooperative scheduling | Running JavaScript must return control to the host or cross a host scheduling boundary before host work progresses | Awaiting an already-settled Promise can continue through microtasks and still delay timers or I/O |
| Actor mailbox | A queue of messages addressed to one actor identity | It is not a Node phase queue or automatically durable |
| Backpressure | The producer is made to wait or reduce demand | Actor-Web's park policy is the direct example
here |
| Load shedding | Excess work is deliberately discarded | Actor-Web's drop policy is the direct example
here |
Model 1: run to completion
JavaScript executes the current stack synchronously. A callback waiting elsewhere cannot preempt it.
console.log('A');
setTimeout(() => console.log('timer'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('B');
For the introductory browser-style model, the output is:
A
B
promise
timer
The initial script owns the stack until it returns. The fulfilled Promise reaction waits in the microtask queue. The zero-millisecond timer becomes eligible for a later task; zero does not mean immediate and does not reserve CPU time.
This model is deliberately smaller than Node.js. It is useful because it isolates the language-level relationship between the current stack, microtasks, and later host callbacks.
Model 2: the Node.js phase loop
Node.js adds host-specific structure. Its current simplified phase model is:
one-time bootstrap only
startup-compatible timers
|
v
recurring loop begins
pending callbacks <------------------------------------+
|
v
idle, prepare (internal)
|
v incoming connections and data
poll <----------------------------------+
|
v
check setImmediate callbacks
|
v
close callbacks
|
v
timers setTimeout and setInterval callbacks
|
+----------------------------------------------+
The essential phases are:
- pending callbacks executes selected I/O callbacks deferred from the previous iteration
- idle, prepare is internal runtime machinery
- poll waits for and processes most I/O events
- check runs
setImmediate(...)callbacks - close callbacks runs callbacks such as abrupt socket close handling
- timers runs eligible
setTimeout(...)andsetInterval(...)callbacks
Starting with Node 20's libuv version, timers normally run after poll in an iteration. A compatibility timer pass can still occur before entering the loop. This detail is one reason a learning diagram should identify the Node version and remain a projection rather than claiming to be an exact execution trace.
Read the current primary source before relying on a phase detail:
nextTick and Promise jobs are checkpoints, not
phases
The official phase diagram intentionally omits
process.nextTick(...) because the
nextTick queue is processed after the current
operation rather than as a libuv phase. Promise reactions use
the Promise microtask queue.
For the bounded CommonJS examples in Node's learning material, reason in this order after the current operation:
current JavaScript operation completes
-> process.nextTick queue
-> Promise microtask queue
-> later macrotask / event-loop callback
Do not turn this into an unqualified global ordering rule. ES
modules begin in an asynchronous context, callbacks can schedule
more jobs, and timer versus setImmediate ordering
depends on where the calls were made.
setImmediate versus
setTimeout(0)
When both are scheduled from the main module, their relative
ordering can vary. When both are scheduled inside an I/O
callback, Node reaches the check phase before the next timer
opportunity, so setImmediate(...) runs first.
That contextual behavior matters to Actor-Web because its
normal scheduler uses setImmediate(...) when the
host provides it.
Model 3: concurrency is not parallel JavaScript
One JavaScript isolate can coordinate many operations that are simultaneously in progress:
- a network request can wait in the operating system
- a filesystem operation can run through libuv's worker pool
- a timer threshold can elapse
- several actor mailboxes can contain messages
The JavaScript callbacks that react to those operations still need turns on the isolate's thread.
setTimeout(() => console.log('timer finally ran'), 0);
const startedAt = performance.now();
while (performance.now() - startedAt < 250) {
// The timer may be ready, but JavaScript cannot run its callback yet.
}
Node's rule of thumb is to keep each callback's work small. A long callback prevents other clients and callbacks on the event-loop thread from getting a turn. See Do not block the event loop.
For CPU-intensive JavaScript that must run in parallel, use a
worker-thread pool, a separate process, or another execution
boundary. Node's worker_threads
documentation distinguishes CPU-intensive JavaScript from
the asynchronous I/O that Node can already handle
efficiently.
Model 4: an actor mailbox is a different queue
The event loop schedules callbacks for an isolate. An Actor-Web mailbox queues messages for one actor identity.
| Host scheduler concern | Actor mailbox concern |
|---|---|
| When can this callback receive a turn? | Which message may this actor handle next? |
| Coordinates timers, I/O, microtasks, and phase callbacks | Coordinates messages addressed to one actor |
| A long callback delays the isolate | A full mailbox applies an actor pressure policy |
| Knows nothing about actor identity | Scopes sequencing and statistics to one actor |
| Does not provide domain ordering | Preserves local FIFO dequeueing without creating global order |
The normal local processing loop gives a state-ownership rule; synchronous test mode is excluded:
During normal processing, Actor-Web does not dequeue the next message for an actor until the current delivery has settled.
That prevents overlapping handling of two messages against the same actor context in the normal loop. Synchronous test mode delivers immediately; concurrent sends can overlap suspended handlers there, so it must not be used as evidence of the normal serialization guarantee. Neither mode makes a JavaScript callback preemptible.
Synchronous work versus await
These handlers are both sequential from the actor's perspective, but they treat the host scheduler differently.
async function cpuHeavyHandler(): Promise<void> {
const until = performance.now() + 250;
while (performance.now() < until) {
// Synchronous work blocks every callback on this isolate.
}
}
async function ioHandler(): Promise<void> {
const result = await readFromNetwork();
consume(result);
}
While ioHandler is awaiting, the host can run
other callbacks. Actor-Web still waits for that delivery before
taking the same actor's next message. Therefore:
- the same actor remains logically serialized in the normal processing loop
- other actors can progress while the awaited operation is pending
- synchronous work before or after the
awaitcan still block the isolate awaitdoes not automatically move CPU work to another thread
Trace a local Actor-Web message
Read these files in order. Runtime evidence in this chapter
is pinned to reviewed Actor-Web revision
0552a23c8d (the main-branch state after PR 56), so
later source changes cannot silently rewrite this lesson:
The current normal local path is:
ActorRef.send(message)
-> actor system resolves the actor address
-> message enters that actor's BoundedMailbox
-> runtime schedules processActorMessages on a future macrotask
-> Node uses setImmediate; other hosts fall back to setTimeout(0)
-> runtime dequeues and awaits one delivery at a time
-> after 100 successful deliveries, remaining work is scheduled for another turn
The scheduler is implemented by
scheduleMacrotask in actor-system-impl.ts.
The same file contains
startMessageProcessingLoop(...),
processActorMessages(...), and the 100-successful-delivery
safety limit.
On Node, setImmediate(...) places the processing
callback in the check phase. On a host without
setImmediate, the timer fallback reaches the host's
timer or task mechanism. The mailbox itself remains outside both
mechanisms.
Why the 100-message batch exists
One processing callback records up to 100 successful deliveries. If more messages remain, Actor-Web schedules another macrotask. The counter advances only after delivery succeeds, so error-heavy workloads that resume after supervision are not currently bounded by this counter.
For successful-delivery batches, this is a cooperative fairness valve:
- it prevents one continuously non-empty mailbox from retaining the same processing round forever
- it gives the host a scheduling boundary at which other callbacks can run
- it does not interrupt one slow handler
- it is not a transaction boundary, acknowledgement, retry, or durability guarantee
Capacity and pressure policy
The current default BoundedMailbox has:
- capacity
1000 - overflow strategy
drop - metrics enabled
- FIFO dequeueing within the mailbox
The three policies express different producer/consumer relationships:
| Policy | Full-mailbox behavior | System design meaning |
|---|---|---|
drop |
Reject the new enqueue and increment
totalDropped |
Shed excess work to protect bounded memory |
fail |
Raise an explicit mailbox failure and increment
totalFailed |
Force the caller/runtime to handle overload as an error |
park |
Return a Promise that settles when dequeueing creates capacity | Apply backpressure by making the producer wait |
The mailbox exposes:
size
capacity
totalEnqueued
totalDequeued
totalDropped
totalFailed
utilizationRatio
These are runtime pressure facts. They do not determine which domain command is safe to drop. Choosing a pressure policy still requires application judgment.
Actor-Web and Erlang/Elixir
The resemblance is real:
- actors/processes own isolated state
- messages wait in per-identity mailboxes
- behavior is separated from runtime machinery
- supervision makes recovery policy explicit
The scheduling guarantee differs. The BEAM uses multiple scheduler threads and time-slices lightweight Erlang processes. Actor-Web runs behavior handlers as JavaScript callbacks on the host isolate. Actor-Web can yield between processing rounds and across awaited operations, but it cannot preempt arbitrary synchronous JavaScript inside a handler.
The useful conclusion is not that one runtime is "more actor-like." It is that the same actor vocabulary sits on different execution substrates and therefore provides different fault-isolation and fairness guarantees.
Failure boundary
After reading this chapter, none of these claims are justified:
- Actor-Web actors are BEAM processes.
- A mailbox makes CPU-heavy JavaScript preemptible.
awaitmoves JavaScript computation to another thread.- A Promise callback always precedes every possible Node callback.
- A zero-millisecond timer runs immediately.
- FIFO within one actor creates global ordering across actors or nodes.
send(...)is durable, acknowledged, or retried.- A 100-message processing batch is a business transaction.
- A bounded mailbox can decide the product meaning of lost work.
Maturity ledger
| Claim | Maturity | Evidence |
|---|---|---|
Actor-Web's internal bounded-mailbox implementation supports
drop, fail, and park |
current | messaging/mailbox.ts and focused direct-mailbox
tests. Spawned actors currently use a fixed dropping mailbox;
the public runtime API does not expose fail or
park selection. |
Normal actor processing uses setImmediate or a
timer fallback |
current | scheduleMacrotask in
actor-system-impl.ts |
| One actor delivery is awaited before its next message is dequeued | current | processActorMessages(...) |
| Processing yields after 100 successful deliveries when more messages remain; failed deliveries do not advance that counter | current | processActorMessages(...) |
| Ordinary send is a durable inbox protocol | deferred | No general transactional mailbox, state, inbox, and outbox guarantee |
| Actor-Web provides BEAM-style preemptive scheduling | deferred | JavaScript host scheduling remains authoritative; VM-level preemption is a non-goal |
Answer the question
JavaScript's one-stack-at-a-time rule prevents two JavaScript frames from executing simultaneously in one isolate, but it does not create actor identity, state ownership, per-actor queues, capacity policy, remote addressing, or supervision. Actor-Web supplies those application/runtime semantics.
Actor-Web's normal processing loop serializes one actor's
messages, which prevents overlapping actor-owned
context mutation when
callers treat message payloads and observed snapshots as
immutable values. Actor-Web does not deep-clone or freeze every
local object reference: mutating a shared payload or
getSnapshot().context outside the actor boundary can
reintroduce shared-state hazards. Synchronous test mode is an
explicit exception: concurrent sends can overlap suspended
immediate deliveries. Normal-loop serialization also does not
prevent event-loop starvation, because the selected handler
still runs as cooperatively scheduled JavaScript. Fairness
depends on short synchronous work, awaited I/O, bounded
processing rounds, and explicit parallel execution boundaries
for CPU-heavy work.
Continue in the workbook
Complete the Week 1 workbook and use the interactive lab to test each layer of the model.