Coding

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

The event loop is the scheduler that runs JavaScript one task at a time, draining the microtask queue after each task before it looks at timers. A timer's delay is a minimum wait, not a guarantee, because a running task cannot be interrupted.

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.

By Film published 1:29 watch

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
texecute≥teligible≥tschedule+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=10 ms,W=80 ms,texecute≈80 msd = 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×20 ms=80 msW = 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.

Transcript

The narration, chapter by chapter. A timestamp opens the film at that moment.

0:00Delay is a minimum
A 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.
0:10Schedule the work
The script prints start, schedules the timer, queues two microtasks, and then holds the thread for eighty milliseconds before printing end, with every line visible in TypeScript.
0:20Run to completion
When our clock reaches ten, the timer becomes eligible in this model, but the current task keeps the thread until eighty, so becoming eligible never interrupts synchronous code.
0:31The checkpoint
After the script returns, the microtask checkpoint runs A first, and the timer keeps waiting because the browser drains this queue before selecting another task.
0:41New microtasks join
A now appends C behind B, so the same checkpoint runs B and then C, giving start, end, A, B, C, timer in both browsers we tested.
0:51A promise still waits
Splitting the work into four chunks changes nothing if each continuation awaits an already fulfilled promise, because those continuations are microtasks and our timer still waits roughly eighty milliseconds.
1:02A chance to run
Task-level yields create a chance to run between chunks: our two browsers ran the timer around forty and twenty milliseconds, showing why yielding improves responsiveness without promising an exact start time.
1:14Three separate moments
Keep timer delay, queue eligibility, and callback execution separate, and for expensive work choose task-level yields or a worker, because a promise continuation alone does not give timer tasks a turn.

Also available as captions (WebVTT), the video file and a Markdown copy of this page.

Sources and model assumptions

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

More visualisations

Embed this film

Paste this into a post, a course page or a newsletter. The film plays in place, with the same controls as here, and credits the source.

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