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 install in src/
  • 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>
  1. Log order when a human clicks the button.
  2. 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.
  3. When does the button actually turn red — relative to which log line?
  4. D vs E: which fires first? Separate what the specification guarantees from what Chrome will typically do, and name the condition that flips it.
  5. 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.