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 pathObserved orderStability
Real trusted clickA F B C G H D E40/40
b.click() from a scriptA F G B C H E D40/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 pathResult
Real trusted clickL1 m1 L2 m2 L3 m3 — checkpoint between listeners
el.click() from scriptL1 L2 L3 m1 m2 m3 — no checkpoint between listeners
dispatchEvent(new MouseEvent)L1 L2 L3 m1 m2 m3
el.click() inside setTimeoutL1 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 primitiverAF frames during the workwall clock
await Promise.resolve()0401 ms
MessageChannel postMessage48403 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

PatternTime
Interleaved offsetWidth read → style write, per element98 ms
Batched: read all, then write all1 ms

98× . Same work, same elements, same final DOM. Only the ordering changed.

F. Why D and E swap order

Dispatch pathrAF firedsetTimeout(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

Strategylong tasksTBTjank frames >32msINP p75INP maxdominant termfilterclonewall
s0 sync handler0075656proc22102098
s1 async, unchunked0076464pres22802132
s2 microtask-chunked88658184192pres121903099
s3 MessageChannel, 5 ms0093232delay46601941
s4 scheduler.yield()00173232pres156201925
s5 worker, naive88818184200pres3315023111
s6 worker, resident data0002424proc8041838

Worst-interaction decomposition (ms):

input delayprocessingpresentation
s00340
s112863
s21145183
s3770
s44721
s50156184
s6120

The four results that matter

  1. 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.

  2. 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.

  3. 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.

  4. The dominant INP term moves between strategies. s0 is proc-bound, s1/s2/s5 are pres-bound, s3 is delay-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.

  1. page.setContent reuses the JS realm. A top-level const b in trial 2 collides with trial 1's, the script throws Identifier '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.
  2. INP is per interaction, not per event. One keystroke emits keydown+keypress+keyup sharing an interactionId. Taking the longest single event reports processing: 0 for a fully synchronous handler, because the longest event is keydown, 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 by interactionId, 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.