Step 1 — Event Ordering and the Microtask Checkpoint
Goal
Predict the execution order of async code without running it, then discover the rule that makes the prediction correct. Establish that the microtask checkpoint follows stack emptiness, not task boundaries.
Prerequisites
- Node 18+;
npm installinsrc/ - Read
CONCEPTS.md§3 (How it works) and §5 (Mental models)
Predict first — do not run anything yet
Write your answers down. The value of this step is the diff between prediction and result; if you run it first, there is no diff and no learning.
<button id="b">Go</button>
<script>
const b = document.getElementById('b');
b.addEventListener('click', () => {
console.log('A');
Promise.resolve().then(() => console.log('B'));
queueMicrotask(() => console.log('C'));
requestAnimationFrame(() => console.log('D'));
setTimeout(() => console.log('E'), 0);
b.style.background = 'red';
console.log('F', b.offsetHeight);
});
b.addEventListener('click', () => {
console.log('G');
Promise.resolve().then(() => console.log('H'));
});
</script>
- Log order when a human clicks the button.
- Log order when the page runs
b.click()from a top-level script. If your answer differs from (1), state the mechanism. If it does not differ, state why not. - When does the button actually turn red — relative to which log line?
- D vs E: which fires first? Separate what the specification guarantees from what Chrome will typically do, and name the condition that flips it.
- One line in that handler is more expensive than it looks. Which, and what does it cost?
Run
cd src
npm run ordering
Expected output:
=== A. EVENT ORDERING (40 trials each) ===
real click 40/40 A F B C G H D E
.click() 40/40 A F G B C H E D
=== B. MICROTASK CHECKPOINT vs 3 LISTENERS ON ONE ELEMENT ===
real trusted click : L1 m1 L2 m2 L3 m3
el.click() from script : L1 L2 L3 m1 m2 m3
dispatchEvent from script: L1 L2 L3 m1 m2 m3
el.click() in setTimeout : L1 L2 L3 m1 m2 m3
=== C. `await` TICK COST ===
sync A1 P1 A2 P2 A3 P3 P4 P5 P6
What just happened
Two things moved between the two orderings, for unrelated reasons. Most explanations conflate them; keeping them separate is the whole point of this step.
The microtasks (B, C, H) moved because the checkpoint rule is:
A microtask checkpoint runs when the JavaScript execution context stack becomes empty.
"At the end of a task" is a consequence of that rule in the common case, not the rule. Experiment
B isolates the real variable across four dispatch paths. The fourth row is decisive: el.click()
inside a setTimeout is a genuine separate task and still does not interleave — so "task vs
script" cannot be the explanation. When the browser dispatches, nothing of yours is on the
stack between listeners, so the stack empties and the checkpoint fires. When you dispatch,
your calling frame stays on the stack and every microtask queues behind it.
Consequence for testing (previews fe-32/fe-33): a suite driving UI with .click() or
fireEvent runs listeners without microtask interleaving. Real users get interleaving. If any
listener's correctness depends on another's promise having settled, the suite is structurally
incapable of catching it. That is not flakiness — it is a harness with different semantics from
the runtime.
The red background appears after E — after every log line. style.background mutates the
CSSOM; it does not paint. Painting happens in the rendering steps, which cannot run until the task
ends and the microtask queue drains.
D vs E is not a property of the primitives — see Step 1b below.
b.offsetHeight is the expensive line. You wrote to style.background on the previous line,
invalidating style; reading a geometric property demands a correct answer immediately, forcing
style recalculation and layout synchronously, inside your handler. One read is cheap; the pattern
is not. Quantified in Step 3.
await costs one microtask tick, not three. The three-tick figure is pre-2019 and still widely
repeated. This is why the module keeps a verification log — platform facts rot.
Step 1b — why D and E swap
npm run frame-timing
Expected output (abridged):
dispatch path | rAF fired at | setTimeout(0) fired at | winner
real trusted click | +5.4ms | +5.6ms | tie (<0.5ms)
real trusted click | +6.1ms | +6.1ms | tie (<0.5ms)
el.click() in script | +10.4ms | +0.1ms | timeout
el.click() in script | +6.4ms | +0.0ms | timeout
Read the margins, not the winner column. Neither primitive changed speed.
- Real click: both fire within a fraction of a millisecond, because input dispatch is scheduled immediately before the rendering steps — the rAF callback and the frame boundary are effectively the same moment. Which one "wins" is decided at sub-millisecond margins. It is reproducible on one machine and guaranteed by nothing.
- Script click: the timer wins decisively (~0.0 ms vs 5–12 ms). A plain script runs at an arbitrary point in the cycle, usually far from the next rendering opportunity, so the already-ready timer task runs immediately.
The generalisation: requestAnimationFrame is not fast or slow. Its latency is your distance
to the next frame, and that distance is set by whatever scheduled you. This is why
setTimeout(fn, 0) "fixes" a layout-timing bug on your machine and breaks under load — you did
not choose a phase of the frame cycle, you chose a coin flip whose bias depends on scheduling
pressure.
Checkpoint
docs/verification.md Checkpoints 1, 2, 3, 6.
Record
Any prediction you got wrong goes in ../fe-00-roadmap/docs/learning-log.md §1 — as the shape
of the confusion, not the topic. "Didn't get microtasks" is useless in three months. "Believed the
checkpoint runs at task boundaries, which predicts interleaving for setTimeout(() => el.click())
— it does not" is a diagnosis.