Rethinking the Event Loop

Background

I have been working on JavaScript for several years, and the event loop — a fundamental but abstract concept — is something I thought I fully understood after watching Philip Roberts's excellent talk at JSConf EU 2014.

However, while reading You Don't Know JavaScript, I came across the Job queue — similar to the task queue, but jobs execute after each task. When I searched for more information, even more unfamiliar terms appeared: macrotask and microtask. Maybe I don't understand the event loop as well as I thought.

Rethinking

event-loop

Diagram from Philip Roberts's talk

Philip's talk demonstrated how asynchronous callbacks (such as setTimeout and I/O) are scheduled into the callback queue (also called the task queue). However, one important piece was missing: microtasks.


According to WHATWG — Event Loops:

  • The event loop coordinates events, user interaction, scripts, rendering, and networking
  • There are two kinds of event loops: browsing contexts and workers
  • An event loop has one or more task queues
  • Each event loop has a microtask queue
  • When the JavaScript execution context stack becomes empty, a microtask checkpoint is performed

According to WHATWG — Integration with the JavaScript Job Queue:

  • The JavaScript specification defines Jobs and Job Queues to specify how Promise operations execute with a clean execution context stack and in a deterministic order
  • When the spec says to call EnqueueJob, it queues a microtask

According to ECMA-262 — Jobs and Job Queues:

  • Execution of a Job can only begin when there is no running execution context and the execution context stack is empty

According to You Don't Know JavaScript — Event Loop:

  • Each iteration of the loop is called a tick. On each tick, if an event is waiting in the queue, it is dequeued and executed

Practice

Consider this code snippet:

setTimeout(function() {
    console.log('A');
}, 0);

new Promise(function executor(resolve) {
    console.log('B');
    resolve();
}).then(function() {
    console.log('C');
});

console.log('D');

The output is B D C A. Here is how the engine processes this step by step.

Steps

JS stack:         []
macrotask queue:  []
microtask queue:  []
output:           []
                |
                | setTimeout(..., 0) → scheduled as macrotask
                v
JS stack:         []
macrotask queue:  [console.log('A')]
microtask queue:  []
output:           []
                |
                | Promise executor runs immediately (per MDN spec)
                | console.log('B') → pushed to JS stack
                v
JS stack:         [console.log('B')]
macrotask queue:  [console.log('A')]
microtask queue:  []
output:           []
                |
                | console.log('B') executes
                v
JS stack:         []
macrotask queue:  [console.log('A')]
microtask queue:  []
output:           [B]
                |
                | resolve() called → .then() callback scheduled as microtask
                v
JS stack:         []
macrotask queue:  [console.log('A')]
microtask queue:  [console.log('C')]
output:           [B]
                |
                | console.log('D') → pushed to JS stack and executes
                v
JS stack:         []
macrotask queue:  [console.log('A')]
microtask queue:  [console.log('C')]
output:           [B, D]

All synchronous code has finished and the JS stack is empty.

Which queue runs next — macro or micro?

The microtask queue runs first. There are two ways to reason about this:

  1. Treat the whole script as a macrotask. Microtasks are drained at the end of each macrotask. Since the script itself is the first macrotask, its microtasks run next.
  2. Alternatively: when the JS stack becomes empty for the first time, the engine checks the microtask queue before picking the next macrotask.

Either way, the result is the same:

                |
                | JS stack empty → drain microtask queue first
                | console.log('C') → pushed to JS stack
                v
JS stack:         [console.log('C')]
macrotask queue:  [console.log('A')]
microtask queue:  []
output:           [B, D]
                |
                | console.log('C') executes
                v
JS stack:         []
macrotask queue:  [console.log('A')]
microtask queue:  []
output:           [B, D, C]
                |
                | microtask queue empty → pick next macrotask
                | console.log('A') → pushed to JS stack
                v
JS stack:         [console.log('A')]
macrotask queue:  []
microtask queue:  []
output:           [B, D, C]
                |
                | console.log('A') executes
                v
JS stack:         []
macrotask queue:  []
microtask queue:  []
output:           [B, D, C, A]

Done.

Conclusion

  • task queue === macrotask queue
  • job queue ≈ microtask queue — per You Don't Know JS, they are equivalent; per ECMA-262, there are two job types (ScriptJobs and PromiseJobs), but the key takeaway is that Promise.then callbacks are microtasks
  • When the JS stack is empty, the engine picks the oldest task from the macrotask queue, executes it, then drains all pending microtasks, then picks the next macrotask
  • Macrotasks: script (whole script), setTimeout, setInterval, setImmediate, I/O, UI rendering
  • Microtasks: process.nextTick, Promise.then, Object.observe, MutationObserver

References

Standards

Books

Articles

← Back