Suppose you’re writing a Turing-machine interpreter that runs in a Web Worker. The UI needs to show a trace — how the machine steps from state to state, what gets written to the tape, how the head moves. For the user to keep up with what’s on screen, the engine needs a short delay between iterations — milliseconds, regularly, on every step. That’s a throttle of the engine between iterations — regular and predictable, not the “Pause” of the UI button (which stops the machine until the user clicks “Continue”).
So where exactly in the iteration cycle does the worker apply that throttle? There are two candidates, and the choice between them pins two contracts at once: the engine’s hooks and the protocol between worker and main thread. Pick the right point, and you design both sides at once; pick the wrong one, and you break both. This article is about that choice.
is a Turing-machine interpreter. The engine has an onStep hook — the handler runs after each machine step. That’s all the engine background you need for this article. For the rest of the engine’s design and previous API changes, see (2).
The first real consumer of the engine is , surfaced two engine majors (#109, #108). This article is the next episode in the life of the same engine: v6.2 → v6.3 → v6.4.
The demo has a RUNNING_AUTO mode: the user picks an interval, clicks “Start”, and the machine steps automatically. Originally the mode was straightforward — the main thread drove step-by-step execution (via runner.step(), a wrapper over the engine’s runStepByStep() in the worker), with setTimeout(intervalMs) inserted between steps for the trace. Simple, and it worked — until the user set a breakpoint.
Breakpoints in the engine fire inside machine.run(): the iteration loop lives in the engine itself, and on hitting a debug-marked state, it pauses itself. runStepByStep(), on the other hand, is a generator: the consumer drives the loop, and breakpoints are invisible to it. : prose was pushing on shape — and the shape cracked.
v6.4.0 added a separate hook — onIter. Signature: (state) => void | Promise<void>. Contract: fires once at the end of every iteration, asynchronously, unconditionally. The name — iteration boundary — describes what the hook does (gives a point at the end of an iteration where you can wait), not why a consumer would use it. And it can be used in different ways: for throttling between trace steps, for syncing with the UI, for aborting a run (e.g., via AbortSignal).
The engine’s hook contract now distinguishes three jobs:
— onStep — synchronous, mid-iteration. Observation of state.
— onPause — asynchronous, conditional (fires only on breakpoints). The boundary between running and stopped.
— onIter — asynchronous, unconditional (fires on every iteration). Coordination: holding the engine for the consumer’s async work.
Three hooks, three jobs. Observation shouldn’t hold the engine; for that, there’s a separate asynchronous hook. Holding shouldn’t hide inside an observation hook; for that, there’s onIter. A pause from a breakpoint is its own scenario altogether; for that, there’s onPause, whose firing event is spelled out in its name.
Contract “At the iteration boundary”
Where the throttle lives is now clear: inside the onIter handler. But the onIter handler runs inside the worker, while the user’s pause comes from outside — from the main thread, on a click. They need a protocol.
This protocol has four mechanisms: a pair of idle/busy messages, a stepRequested flag, a synthetic paused event, and an intervalMs field in resume. Each one closes a specific problem that the throttle’s location creates.
The idle and busy messages: pausing the watchdog
The watchdog timer mentioned earlier starts to get in the way once we’re in onIter.
A throttle in onIter, on every iteration, can easily exceed the timeout — especially if the user dials intervalMs to “slow” (the demo lets you set the interval to, say, two seconds). The result is a false alarm: the worker isn’t hung, it’s just waiting.
The solution: the worker itself tells the main thread when it’s “working” and when it’s “waiting”. Before await-ing the throttle in onIter, the worker sends an idle message. After the throttle resolves, it sends a busy. The main thread suspends the watchdog for the gap between idle and busy. The watchdog still catches real hangs (e.g., an unreachable halt state) but no longer confuses them with legitimate trace slowdown.
Without this wrapping, the watchdog has two bad options. Either pick a generous threshold — then real hangs go undetected (or detected too late). Or pick a tight threshold — and you raise the probability of a false alarm (the worker gets killed on a legitimate delay). The idle/busy messages resolve both at once.
Step is a flag inside the worker, not engine state
When the user clicks “Step”, the main thread sends the worker a resume message with a step: true flag — “advance the machine one iteration and pause again”. The implementation: the worker sets stepRequested = true. On the next onIter, the handler sees the flag, resets it to false, synthesizes a paused event, and waits again. The machine has run exactly one iteration.
The key point: “step” is a flag inside the worker, not part of the engine’s state. The engine doesn’t know what “step” means — it simply finds itself in a waiting state again on the next iteration. The coordination lives outside the engine. onIter remains a thin await hook — it doesn’t mutate the engine, it only pauses the engine’s execution. That’s what made onIter viable as a contract: there’s no “step-specific logic” in it, only a Promise the worker controls itself. If “step” had been part of the engine’s state, the onIter contract would have to extend to “and also flip this flag”, and the hook would stop being thin.
No throttle needed when Step is clicked
When the user clicks “Step”, a throttle is redundant — the user plays the role of the timer, clicking as many times and as fast as they need. The implementation reflects this: on Step, intervalMs is ignored, and the onIter handler doesn’t insert a wait. This is the cleanest expression of “observation ≠ coordination” inside the worker: when the user is in control, the engine follows.
Synthetic pause on click
The scenario: the machine is in RUNNING_AUTO — the engine is running with onIter awaiting intervalMs between iterations. The user clicks the “Pause” button in the UI. The click arrives on the main thread. The main thread sends the worker a pause message. At that moment, the worker is holding the engine in a throttle — the Promise hasn’t resolved yet. What do we do?
The solution is to synthesize the same kind of pause as one from a breakpoint. The UI already has a handler in place: on paused from the worker, it switches to “stopped”. If the “Pause” click went down a different path, a second branch with the same behavior would appear. That’s why the worker synthesizes paused — to the UI, a “Pause” click is indistinguishable from a breakpoint firing.
The worker cancels the throttle (resolves it forcibly, ahead of schedule), sets stepRequested = false, and, from inside onIter after returning from the wait, dispatches a paused event — synthetic, not one that arose from the engine’s normal flow. Under the hood, the two scenarios differ (one cancels the throttle, the other lets it finish naturally), but the contract from outside is one — a single “pause” scenario.
intervalMs is a parameter of resume, not run()
The throttle in onIter is sized by intervalMs. Where does the worker get that value from? Not from the parameters passed to run() at start. From the latest resume message: each one arrives carrying a current intervalMs from the main thread.
The reason is that the user must be able to change the delay between throttles during the run. The value in the UI is read at click time, not at run() start; the next resume carries the new value, and starting with the next iteration, the throttle adjusts. A small cost for the protocol, a big win for the UX.
Duality, explicit
The throttle lives in one point of an iteration. From the engine’s perspective, that point is the onIter handler: a hook called once at the end of each iteration, asynchronously. From the protocol’s perspective, the same point is the gap between idle and busy messages — a gap where the watchdog is suspended, a “Pause” click can softly cancel the throttle, and intervalMs is read from the latest resume.
One point, two APIs. Every downstream design choice depends on this one. Put it in the middle of an observation hook, and you break both sides at once: the docs on a widened onStep can’t be written without a “but”, the idle/busy wrapping has nothing to attach to (the same hook stands for both observation and waiting), and the synthetic paused event has to be dispatched from an unnatural place. Without a clean iteration boundary, both the hook and the protocol suffer.
Conclusion
Separating observation from coordination is the reason the engine’s hook contract distinguishes three jobs instead of merging them into one. Gluing them together is the temptation v6.2.0 succumbed to. And the only thing that let me realize the approach was wrong within a few hours was that the engine’s README couldn’t be written honestly, and the demo’s smoke test wouldn’t pass. Two almost-free design reviews ( (engine, v6.2 → v6.4) and machines-demo (the demo).
SOCIAL SHARE CARD GENERATOR