Step 2 — Does "Chunking" Actually Chunk?

Goal

Establish, by measurement, that microtask-based chunking creates zero rendering opportunities — and feel the difference in a real browser before seeing the number.

Prerequisites

  • Step 1 complete
  • npm install in src/

Predict first

Two loops. Same 400 ms of work, same 5 ms slices, pausing between every slice. The only difference is how they pause:

const yieldMicro = () => Promise.resolve();                       // A
const yieldTask  = () => new Promise(r => { resume = r;           // B
                          channel.port2.postMessage(0); });

Both pause constantly. Write down: how many frames does each render in 400 ms?

Run it by hand first

cd src
npm run serve       # http://localhost:8080/starvation.html

Click the left button (await Promise.resolve()), watch frames drawn. Then the right button (postMessage).

While the left run is going:

  • Try selecting this text. Try scrolling.
  • Watch the red square. It keeps spinning. That is not a bug — it is the most useful thing on the page.

Then measure

npm run starvation

Expected output:

=== D. MICROTASK vs TASK CHUNKING (400ms of work, 5ms slices) ===
  yield primitive              | frames rendered | slices | wall clock
  -----------------------------|-----------------|--------|-----------
  await Promise.resolve()      |               0 |     80 | 400ms
  MessageChannel postMessage   |              49 |     80 | 404ms

What just happened

Zero frames. Not "fewer frames" — zero. The specification requires the microtask queue to be empty before the browser may proceed to the rendering steps. A loop that re-enqueues itself is never empty, so the rendering steps are never reached. You did not yield; you built a queue that refills faster than it drains.

The slice counts and wall clocks are nearly identical (80 vs 80, 400 ms vs 404 ms). That equality is what makes this a controlled experiment: both runs did the same work, in the same number of pieces, in the same time. Only the rendering differs.

The red square is the lesson most worth keeping. It animates transform, which runs on the compositor thread, which a blocked main thread cannot stop. So during a total main-thread freeze the page still looks alive.

Two consequences:

  1. A spinner is a terrible liveness indicator. "The page looks alive" and "the page is responsive" are independent claims, and most loading UI asserts the first while users care about the second.
  2. This is the threading rule behind "animate transform, not left". It is not a style preference — it decides which thread owns the animation. The same mechanism explains why one non-passive wheel listener hands your scroll performance to your worst long task: the compositor must ask the main thread whether preventDefault() will be called.

Scrolling may have worked during the freeze; text selection did not. Scroll is compositor; selection is main thread. You just observed the thread boundary directly.

Checkpoint

docs/verification.md Checkpoint 4.

Going deeper

WORK_MS=3000 npm run starvation     # longer freeze — watch the tab become unkillable-feeling

Then ask: during the microtask run, can you open DevTools? Can you set a breakpoint? What does that tell you about where DevTools' own UI runs?