Coding

The event loop: why a 10 ms timer waits 80 ms

Trace an executable TypeScript example through a running task, a nested microtask checkpoint and a waiting timer, then compare promise continuations with real task yields in two browser engines.

Inspect captured example
Recorded engine
Loading film

Delay is a minimumA ten-millisecond timer is a request to wait at least ten milliseconds, and this example shows why its callback still cannot run when that wait ends.

Narrated with James Phoenix's AI voice.

10ms
Requested timer delay
80.0–80.0ms
Observed timer callback
3Chromium 145.0.7632.6
Captured runs

Captured chromium output: start → end → A → B → C → timer. Controls inspect stored browser evidence and seek to the corresponding chapter; the narrated example stays fixed. Observed times are local measurements, not scheduling guarantees.

A timer asks the browser to wait before making a callback eligible. It does not reserve the thread at a deadline. Press play and follow one TypeScript script: it prints start, schedules a 10 ms timer, queues A and B, performs 80 ms of synchronous work, and prints end. The busy helper repeatedly reads performance.now() until the requested interval has passed; it is deliberately blocking work used to expose the scheduling mechanism.

The currently running task keeps the thread until it returns. The film's illustrative clock reaches the timer threshold at 10 ms while busy(80) is still executing. Eligibility changes, but execution does not interrupt the script. In this example the browser can only run the callback after the long task and the following microtask checkpoint have finished.

At that checkpoint, A runs first and queues C. C joins behind the already queued B. The checkpoint keeps taking the oldest microtask until its queue is empty, including microtasks appended by other microtasks. The resulting console order is start, end, A, B, C, timer. The TypeScript fixture executes in Chromium and WebKit, three times each, and the film's finite pure model reproduces that order.

Splitting the same work into four 20 ms chunks is not sufficient by itself. With await Promise.resolve(), each async continuation is a microtask, so the checkpoint keeps running the remaining chunks before the timer gets a turn. With await new Promise<void>((resolve) => setTimeout(resolve, 0)), the current continuation suspends until a later timer task resolves its promise. That resolution queues the async continuation as a microtask. Other tasks now have an opportunity to run between chunks.

The local captures make that opportunity concrete. The observed 10 ms timer ran at about 80 ms after the long task or promise yields. With timer task yields it ran around 40 ms in Chromium and 20 ms in WebKit. Neither value is a browser guarantee. The controls select a stored three-run trace and seek to its film chapter; the engine control changes the measured range and console order beneath the player. These are genuine observations of the same TypeScript source, with capture version and source hashes kept beside the fixture.

For work that affects responsiveness, keep requested delay, eligibility and actual callback execution separate. Yield to another task when appropriate, or move substantial computation to a worker. A promise continuation alone does not give timer tasks a turn. This explainer models one agent, a finite microtask queue and one timer task source. It does not model rendering opportunities, input prioritisation, cross-agent parallelism, background throttling or exact browser timer insertion times. In a real application, profile the work and measure responsiveness under representative load.

The maths

Delay and execution are separate
texecuteteligibletschedule+dt_{\mathrm{execute}} \ge t_{\mathrm{eligible}} \ge t_{\mathrm{schedule}} + d

The timer delay d is a minimum waiting condition. The film models eligibility at the threshold; the browser can make the callback runnable later and must still obtain a turn to execute it.

The worked example
d=10ms,W=80ms,texecute80msd = 10\,\mathrm{ms},\quad W = 80\,\mathrm{ms},\quad t_{\mathrm{execute}} \approx 80\,\mathrm{ms}

The synchronous busy loop holds this agent for 80 ms. Its queued microtasks run before the timer callback. Small logging and scheduling costs make measured timing approximate.

Chunking does not choose a queue
W=4×20ms=80msW = 4 \times 20\,\mathrm{ms} = 80\,\mathrm{ms}

Awaiting a fulfilled promise splits the source into continuations but keeps them in the microtask checkpoint. Awaiting a timer gives another task an opportunity; its resolver then schedules the async continuation as a microtask.

Sources and model assumptions

Follow the original mechanism behind this explainer. The interactive examples identify their toy data and simplifying assumptions above.

More visualisations

Building with language models?

These explainers come out of the work. If you want the same thinking applied to your own system, that is what I do.

See how I can help