M01 — Measured Results
All numbers produced on Chrome for Testing 149.0.7827.55 (arm64 macOS), driven by
playwright-core over CDP. Scripts live in ../src/ (run-ordering.mjs, run-starvation.mjs, run-layout.mjs,
run-frame-timing.mjs, run-benchmark.mjs) and are reproducible with npm run all. Reproduce before trusting: browsers move, and several of these numbers are
version-sensitive.
Ordering results: 40 trials each, 100 % stable. Lab results: median of 3 runs,
6× CPU throttling via Emulation.setCPUThrottlingRate, 200 000-row dataset, 8 trusted
keystrokes (november) at 150 ms intervals.
A. Prediction Challenge 1 — ordering
| Dispatch path | Observed order | Stability |
|---|---|---|
| Real trusted click | A F B C G H D E | 40/40 |
b.click() from a script | A F G B C H E D | 40/40 |
Two things move: the microtasks (B, C, H) and D vs E. Different mechanisms.
B. Microtask checkpoint vs. 3 listeners on one element
| Dispatch path | Result |
|---|---|
| Real trusted click | L1 m1 L2 m2 L3 m3 — checkpoint between listeners |
el.click() from script | L1 L2 L3 m1 m2 m3 — no checkpoint between listeners |
dispatchEvent(new MouseEvent) | L1 L2 L3 m1 m2 m3 |
el.click() inside setTimeout | L1 L2 L3 m1 m2 m3 |
The discriminator is not "task vs script" and not "trusted vs synthetic." The
setTimeout case is its own task and still shows no interleaving. The rule is the spec's:
a microtask checkpoint runs when the JS execution context stack becomes empty. Browser-
initiated dispatch has an empty stack between listeners; script-initiated dispatch has your
frame still on the stack.
C. await tick cost
sync A1 P1 A2 P2 A3 P3 P4 P5 P6
await null costs exactly one microtask tick — A1 interleaves with P1, A2 with P2.
(Pre-2019 this was three ticks; blog posts asserting that are stale.)
D. Microtask "chunking" does not chunk — 400 ms of work, 5 ms slices
| Yield primitive | rAF frames during the work | wall clock |
|---|---|---|
await Promise.resolve() | 0 | 401 ms |
MessageChannel postMessage | 48 | 403 ms |
Identical structure, identical slice budget, same total time. One renders 48 frames; the other renders nothing. This is the tasks-vs-microtasks asymmetry in one table.
E. Forced synchronous layout — 800 elements
| Pattern | Time |
|---|---|
Interleaved offsetWidth read → style write, per element | 98 ms |
| Batched: read all, then write all | 1 ms |
98× . Same work, same elements, same final DOM. Only the ordering changed.
F. Why D and E swap order
| Dispatch path | rAF fired | setTimeout(0) fired |
|---|---|---|
| Real click | +2.1 / +6.0 / +6.4 / +6.9 ms | +2.1 / +6.2 / +6.6 / +7.0 ms |
.click() from script | +7.0 / +11.4 / +4.6 / +3.3 ms | +0.1 / +0.0 / +0.0 / +0.0 ms |
Neither primitive changed speed. Input dispatch is scheduled immediately before the rendering steps, so a rAF registered inside a click handler is microseconds from running, while the timer task waits for the frame to finish. A script running at an arbitrary point in the cycle is typically far from the next rendering opportunity, so the timer — already ready — wins easily.
The spec guarantees nothing about D vs E. The observed order is a function of where in the frame cycle the initiating task sits.
Lab — five strategies (+2 controls), 200k rows, 6× CPU throttle
| Strategy | long tasks | TBT | jank frames >32ms | INP p75 | INP max | dominant term | filter | clone | wall |
|---|---|---|---|---|---|---|---|---|---|
| s0 sync handler | 0 | 0 | 7 | 56 | 56 | proc | 221 | 0 | 2098 |
| s1 async, unchunked | 0 | 0 | 7 | 64 | 64 | pres | 228 | 0 | 2132 |
| s2 microtask-chunked | 8 | 865 | 8 | 184 | 192 | pres | 1219 | 0 | 3099 |
| s3 MessageChannel, 5 ms | 0 | 0 | 9 | 32 | 32 | delay | 466 | 0 | 1941 |
s4 scheduler.yield() | 0 | 0 | 17 | 32 | 32 | pres | 1562 | 0 | 1925 |
| s5 worker, naive | 8 | 881 | 8 | 184 | 200 | pres | 33 | 1502 | 3111 |
| s6 worker, resident data | 0 | 0 | 0 | 24 | 24 | proc | 80 | 4 | 1838 |
Worst-interaction decomposition (ms):
| input delay | processing | presentation | |
|---|---|---|---|
| s0 | 0 | 34 | 0 |
| s1 | 1 | 28 | 63 |
| s2 | 1 | 145 | 183 |
| s3 | 7 | 7 | 0 |
| s4 | 4 | 7 | 21 |
| s5 | 0 | 156 | 184 |
| s6 | 1 | 2 | 0 |
The four results that matter
-
s2 is worse than doing nothing. Microtask "chunking" produced 8 long tasks, 865 ms of blocking, 3× the INP of the naive baseline, and 5.5× the filter time of s3. It added the full cost of chunking (clock polling, loop restructuring) and bought none of the benefit. A code reviewer who sees "chunked with
await" and approves it has shipped a regression that looks like an optimisation. -
s5 vs s6: the worker is not the win — the boundary is. Same worker, same algorithm, same compute (33 ms vs 80 ms off-thread). s5 posts 200 000 objects per keystroke: 1502 ms of structured clone, on the main thread, which is exactly what a worker was supposed to avoid. s6 keeps the data resident and posts an 8-byte string: 4 ms. 376× difference in boundary cost, from one architectural decision.
-
s3 vs s4: same INP, 3.4× the wall clock. Both hit 32 ms INP with zero long tasks.
scheduler.yield()took 1562 ms of elapsed filter time against s3's 466 ms, because its continuations are scheduled behind rendering rather than ahead of it. Whether that is a win depends entirely on whether anything is waiting for the result. This is the trade-off from the module made concrete — and note that s4's jank-frame count (17) is the worst of the non-pathological strategies. -
The dominant INP term moves between strategies. s0 is
proc-bound, s1/s2/s5 arepres-bound, s3 isdelay-bound. Three different diagnoses, three different fixes. A team with a single "INP is bad" dashboard cannot tell these apart, and will apply one fix to all of them.
Harness bugs found while building this — keep them
Both produced clean, plausible, entirely fake data. This is the failure mode of performance work.
page.setContentreuses the JS realm. A top-levelconst bin trial 2 collides with trial 1's, the script throwsIdentifier 'b' has already been declared, no listeners attach, and the harness reports an empty log — indistinguishable from "the event didn't fire." 39/40 trials were silently void. Fix: IIFE-wrap page scripts, and assert the harness is alive before every trial.- INP is per interaction, not per event. One keystroke emits
keydown+keypress+keyupsharing aninteractionId. Taking the longest single event reportsprocessing: 0for a fully synchronous handler, because the longest event iskeydown, whose own processing is empty. Taking the span from first start to last end instead reports ~250 ms for everything, because it swallows the inter-keystroke gap. Correct: group byinteractionId, latency = longest event's duration, processing = sum across the group.
Rule this establishes: a measurement harness needs its own failing test before you trust a single number it produces. Both bugs above pointed the right direction while being quantitatively meaningless — the most dangerous kind of wrong.