Actor-Web Workbook · Week 1
Week 1 Workbook: JavaScript Concurrency and Actor Mailboxes
Companion material
Preflight
Estimated time: five to seven hours across three sessions.
You need:
- Node.js 20 or newer for the documented current timer-phase behavior
- working knowledge of functions, arrays, Promises, and
async/await - a browser for the interactive lab
- a disposable terminal for bounded timing experiments
Safety rules:
- keep every busy loop at or below 250 milliseconds
- bound recursive microtask experiments to a fixed count
- never run an infinite loop to prove starvation
- use fake messages and effects
- stop if a local machine becomes unresponsive
Prediction sheet
Complete the prediction and confidence columns before opening the guide or advancing the matching animation.
| Prompt | Prediction | Confidence | Observation | Revised rule |
|---|---|---|---|---|
| What is the output order of synchronous logs, a fulfilled Promise reaction, and a zero timer? | ||||
| Can a ready timer interrupt a running callback? | ||||
Where does setImmediate(...) run in Node? |
||||
What runs first when setImmediate and a zero
timer are scheduled inside an I/O callback? |
||||
| While actor A awaits I/O, can actor B progress in the same isolate? | ||||
| While actor A runs synchronous CPU work, can actor B progress in the same isolate? | ||||
| What happens to message 1001 in a full default mailbox? | ||||
Does one-message-at-a-time handling make
send(...) durable? |
Session 1: JavaScript and Node scheduling
Lab route
Open the interactive lab, choose JavaScript queues, and run these scenarios in order:
For every phase, read the correlated source highlight: Executing marks code running now, Related marks code that scheduled or explains queued work, and Host phase means the runtime is progressing without executing that source line. Select any correlated code line to jump to its next matching phase.
- Promise before timer
- Blocking callback
- Awaiting I/O yields
Before every Next action, say where the work token will move and which output line will appear next.
Then choose Node.js phases and run:
- I/O through poll, check, and timers
nextTick, Promise, and macrotask
Exercise 1A: ordering by prediction
Write the expected output before running:
console.log('script:start');
queueMicrotask(() => console.log('microtask:one'));
Promise.resolve().then(() => {
console.log('promise:one');
queueMicrotask(() => console.log('microtask:two'));
});
setTimeout(() => console.log('timer'), 0);
console.log('script:end');
Run it in a browser console and Node. Record:
- output order
- which lines run on the initial stack
- which callbacks are microtasks
- which callback requires a later host task
- whether multiple runs differ
Exercise 1B: Node context changes ordering
Save this as a CommonJS file such as
ordering.cjs and run it several times:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Then move the same pair into an I/O callback:
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
Explain the difference using poll,
check, and timers. Do not describe the
main-module ordering as a stable guarantee.
Exercise 1C: bounded microtask starvation
let remaining = 10_000;
function continueInMicrotask() {
remaining -= 1;
if (remaining > 0) queueMicrotask(continueInMicrotask);
}
setTimeout(() => console.log('timer'), 0);
queueMicrotask(continueInMicrotask);
Observe when the timer runs. Then answer:
- Why does the JavaScript call stack not overflow?
- Why can the timer still be delayed?
- What would make the experiment unsafe?
Session 2: cooperative scheduling and parallelism
Exercise 2A: measure timer delay
const requestedAt = performance.now();
setTimeout(() => {
console.log({ actualDelay: performance.now() - requestedAt });
}, 25);
const blockStartedAt = performance.now();
while (performance.now() - blockStartedAt < 250) {
// Deliberate, bounded failure injection.
}
Record the requested threshold and actual delay. Explain the result without saying the timer is broken.
Exercise 2B: blocking versus awaiting
Create two small functions:
async function blockFor250Ms() {
const until = performance.now() + 250;
while (performance.now() < until) {
// bounded experiment
}
}
async function waitFor250Ms() {
await new Promise((resolve) => setTimeout(resolve, 250));
}
For each function, schedule a separate zero timer before calling it. Record whether the timer can run while the function is unfinished.
Explain the distinction:
unfinished operation != JavaScript thread occupied
Exercise 2C: choose the execution boundary
For each workload, choose one response and state the maximum input and latency budget behind your decision:
- keep synchronous because work is small and bounded
- partition across event-loop turns
- use asynchronous I/O
- use a worker-thread pool
- use a separate process or remote worker
- reject or shed work before it grows
| Workload | Choice | Maximum input | Latency budget | Why |
|---|---|---|---|---|
| Parse a 2 KB command payload | ||||
| Hash a multi-gigabyte file | ||||
| Wait for a database response | ||||
| Apply 50 deterministic FSM transitions | ||||
| Render an unbounded recursive template |
Session 3: build and inspect a mailbox
Exercise 3A: implement a bounded FIFO
Implement the smallest queue that satisfies this contract before reading Actor-Web's mailbox implementation:
interface QueueStats {
readonly size: number;
readonly capacity: number;
readonly totalEnqueued: number;
readonly totalDequeued: number;
readonly totalDropped: number;
readonly totalFailed: number;
}
type OverflowPolicy = 'drop' | 'fail' | 'park';
interface BoundedFifo<T> {
enqueue(value: T): boolean | Promise<boolean>;
dequeue(): T | undefined;
readonly stats: QueueStats;
}
Build in four passes:
- A roughly 20-line FIFO with a fixed capacity and
drop. - Add
failwith an explicit error. - Add
parkby retaining a bounded waiter and settling it when dequeueing creates capacity. - Add the statistics without changing queue ordering.
For park, increment totalEnqueued
only when the waiting value is actually inserted into the FIFO
and its Promise settles. A pending waiter is not yet an enqueued
value.
Tests to write:
- values dequeue in FIFO order
- enqueue succeeds below capacity
dropreturnsfalseand incrementstotalDroppedfailthrows and incrementstotalFailedparkremains pending while full and settles after a dequeue- statistics remain unchanged while a sender is parked, then
totalEnqueuedincrements exactly once when dequeueing admits it - one dequeue admits at most one parked sender
- statistics remain internally consistent
Do not copy Actor-Web first. The comparison is valuable only if you encounter the design choices yourself.
Exercise 3B: compare with Actor-Web
The runtime evidence links in exercises 3B and 3C are pinned
to reviewed Actor-Web revision 0552a23c8d, matching
the guide.
Read mailbox.ts
and its adjacent tests. Locate:
MailboxConfigOverflowStrategyenqueue(...)dequeue(...)parkSender(...)tryUnparkSender(...)- mailbox statistics
Complete the comparison:
| Concern | Your queue | Actor-Web BoundedMailbox |
Why the difference matters |
|---|---|---|---|
| Default capacity | |||
| Default policy | |||
| FIFO storage | |||
| Parked senders | |||
| Stop behavior | |||
| Metrics | |||
| Error representation |
Exercise 3C: trace Actor-Web scheduling
Read:
Find and annotate:
- the public
send(...)delegation - the local enqueue path
scheduleMacrotaskstartMessageProcessingLoop(...)processActorMessages(...)- the
awaitaround local delivery - the 100-message batch limit
- rescheduling when messages remain
Draw your own version of this path from memory:
send -> route -> mailbox -> schedule -> dequeue -> await handler -> yield
Label each arrow as JavaScript, Node/browser host, Actor-Web runtime, or application behavior.
Exercise 3D: pressure policies in the lab
Choose Actor-Web overlay in the interactive
lab. Run Mailbox overflow three times,
selecting drop, fail, and
park before advancing.
Use capacity three and messages A through E. Record:
| Policy | A-C | D | E | Producer outcome | Stats changed |
|---|---|---|---|---|---|
drop |
|||||
fail |
|||||
park |
Then answer: which policy would you choose for telemetry, an approval command, and a replaceable pointer-move update? The mailbox cannot answer this for you; state the domain reasoning.
Failure experiment: two actors, one isolate
Place actor A and actor B in the same local Actor-Web runtime
and JavaScript isolate. Give actor A a primary work message, a
FOLLOW_UP message, and a READ_STATE
request. Give actor B a fast message.
Record these completion events with timestamps:
A_PRIMARY_STARTED
A_PRIMARY_DONE
A_FOLLOW_UP_DONE
B_DONE
TIMER_FIRED
CPU_RESULT
For every variant, use this order:
- configure actor A's primary handler to emit
A_PRIMARY_STARTED, then schedule the zero timer immediately before starting the variant's work - hold actor processing in a controlled harness until the three admissions below are verified; if using the live scheduler instead, constrain local routing, directory lookup, and send hooks so no admission yields past the scheduled processing turn
await actorA.send(PRIMARY)and verify that the primary message was admittedawait actorA.send(FOLLOW_UP)and verify its admission after the primary messageawait actorB.send(FAST)and verify actor B's admission before actor A begins processing- after all three admissions are established, call
fixture.releaseActorAProcessingFirst()(or the equivalent harness operation) and do not release actor B's callback beforeA_PRIMARY_STARTEDis observed - wait for all expected completion events with a fixed five-second deadline
- if the deadline expires, record every missing event and cleanly terminate actors, worker threads, child processes, and outstanding timers before the variant fails
- compare timestamps rather than relying on log order alone
Run these variants:
- Synchronous CPU: actor A performs a bounded 250-millisecond busy loop.
- Asynchronous wait: actor A awaits a 250-millisecond timer.
- Worker thread: actor A remains in the local runtime and awaits a Promise settled by a worker-thread result. The worker owns the CPU-heavy JavaScript.
- Child process: actor A remains local and
awaits a correlated result from a separate process. Record
process completion separately from actor A's
A_PRIMARY_DONEevent.
Complete the observation matrix:
| Work | A_FOLLOW_UP_DONE waits? |
B_DONE progresses? |
TIMER_FIRED progresses? |
Parallel CPU? | Completion evidence |
|---|---|---|---|---|---|
| Actor A synchronous busy loop | |||||
| Actor A awaits asynchronous timer | |||||
| Actor A awaits worker-thread result | |||||
| Actor A awaits child-process result |
Keep state isolation as a separate assertion from timing:
- Confirm that
ActorRefdoes not expose a directactorA.contextfield. Actor B can observe context through the supportedactorA.getSnapshot().contextAPI; that observation is not promised to be a detached clone or a mutation boundary. - Have actor A answer
READ_STATEwith a detached JSON-safe snapshot. - Let actor B mutate its local copy of that snapshot.
- Ask actor A for state again and assert that actor A's state is unchanged.
The detached copy in step 2 is an application discipline, not
automatic deep cloning by Actor-Web. Passing shared mutable
object references in local messages would reintroduce
shared-state hazards and should be recorded as a deliberate
failure of the actor boundary. Do not mutate
getSnapshot().context in this experiment: the
current API exposes it for observation and does not promise to
deep-clone or freeze nested values.
The experiment should prove both:
- actor B can observe actor A through
getSnapshot()but has no direct context field or actor-owned mutation API, and mutating a deliberately detachedREAD_STATEreply does not change actor A - state isolation does not guarantee CPU isolation inside one JavaScript isolate
The 101-message fairness experiment
Use the lab's 101-message fairness batch scenario, then reproduce the idea with a test or trace. Separate admission evidence from processing evidence:
- Create a direct
BoundedMailboxfixture with capacity at least101and a non-dropping policy such asfail. - Enqueue 101 sequence-numbered no-op messages whose handlers complete successfully, and track every enqueue result.
- Before measuring processing, assert
totalEnqueued === 101,totalDropped === 0, andtotalFailed === 0. - For the actor-processing trace, establish that all 101
messages are admitted before processing turn 1. Either hold the
scheduler in a controlled harness until mailbox facts prove the
boundary, or explicitly constrain local routing, directory
lookup, and send hooks so their admissions settle before the
scheduled processing macrotask. Concurrent
send(...)invocation alone does not prove the 100/1 split; a resolved drop path also does not prove acceptance. - Record the first processing round and identify the rescheduling boundary after successful delivery 100.
- Record message 101 in the next processing round and assert that every sequence number from 1 through 101 was handled exactly once in this local experiment.
Explain why the boundary improves cooperative fairness for successful-delivery batches, why failed deliveries do not currently advance the counter, and why the boundary is not:
- a CPU preemption point inside a handler
- a durable checkpoint
- an acknowledgement of all 100 messages
- a business transaction
Exit assessment
Answer without opening the guide:
- Why does a fulfilled Promise reaction precede a later timer in the introductory example?
- Why is
process.nextTick(...)absent from Node's phase diagram? - What are the two jobs of the poll phase?
- Where does
setImmediate(...)run? - Why can
setImmediateand a zero timer change order depending on context? - What must happen before a queued callback executes?
- Can a ready timer interrupt an actor handler?
- What changes when an actor handler awaits I/O?
- What does one-message-at-a-time handling protect?
- Which shared resource still lets one actor delay another?
- Why does Actor-Web yield after a 100-message batch?
- Compare
drop,fail, andparkas system-design decisions. - What execution boundary enables parallel CPU-intensive JavaScript?
- Why does a mailbox not make
send(...)durable?
Teach-back prompt
Explain this statement to another JavaScript developer:
The normal Actor-Web processing loop solves ownership and ordering for one actor. Synchronous test mode is excluded; cooperative event-loop scheduling solves neither CPU isolation nor preemption.
Your explanation must use one code example, one Actor-Web source symbol, and one failure observation.
Completion evidence
Copy the learning-record template (Markdown) from the repository and include:
- the completed prediction sheet
- your bounded FIFO implementation and policy tests
- the blocking-versus-awaiting observation matrix
- the Actor-Web source trace
- one screenshot or written trace from each lab projection
- your exit-assessment answers
- the teach-back explanation
You are ready for Week 2 when you can trace a message from
send(...) to the handler and explain, without
contradiction, why normal-loop actor state is serialized while
the JavaScript isolate can still be starved, and why synchronous
test mode is not evidence for that serialization guarantee.
Required learning-product verification:
pnpm test:learning
pnpm exec markdownlint-cli2 --config .markdownlint.jsonc \
"docs/learning/**/*.md" \
"docs/actor-web-architecture-study-guide.md" \
"README.md"
pnpm test:docs
This repository does not expose a root
verify.sh; do not substitute a nonexistent command
for the real project scripts above.
Supplementary focused Actor-Web runtime evidence:
pnpm --filter @actor-web/runtime exec vitest run \
src/unit/message-delivery.test.ts \
src/integration/async-messaging.test.ts