How it works
ECMA-262 describes Jobs and Host Operations to Enqueue Jobs. A Job is an Abstract Closure with no parameters, run when no other computation is in progress in that agent. The requirements are: the execution context stack must be empty when it starts and again when it ends, only one job is active at a time, and it runs to completion. The spec also says hosts may treat jobs non-uniformly. Browsers and Node.js give promise jobs higher priority than other work. The hook HostEnqueuePromiseJob requires only that these jobs run in the same order they were enqueued.
The HTML Standard fills in the rest. Its event loop processing model, in the order the spec writes the steps, is:
- Choose a task queue that has a runnable task, in an implementation-defined way.
- Remove the oldest task from that queue and run it.
- Perform a microtask checkpoint.
- Report long tasks and update bookkeeping, then, for a window event loop, run the update the rendering steps when the browser decides a rendering opportunity exists. Rendering is covered in the browser topic.
HTML's HostEnqueuePromiseJob queues the job on the microtask queue. The microtask checkpoint dequeues and runs microtasks while the queue is not empty. A flag, performing a microtask checkpoint, prevents re-entrant checkpoints when a callback finishes while a checkpoint is already running. After the queue is empty, the checkpoint notifies about rejected promises, which is why unhandled rejection reports arrive after microtasks.
Here is a runnable model of the idea. It is a teaching model, not an engine:
const taskQueue = [];
const microtaskQueue = [];
function runEventLoop() {
while (taskQueue.length > 0) {
taskQueue.shift()();
while (microtaskQueue.length > 0) microtaskQueue.shift()();
}
}
taskQueue.push(() => {
console.log('task 1');
microtaskQueue.push(() => console.log('microtask from task 1'));
});
taskQueue.push(() => console.log('task 2'));
runEventLoop();
// → task 1
// → microtask from task 1
// → task 2
Edge cases
A microtask that keeps queueing microtasks prevents the loop from ever reaching the next task, because the checkpoint runs until the queue is empty. Timers, input and rendering starve. See the ordering question.
Different task sources are different queues. HTML lets the browser choose between queues, so two callbacks from different sources, such as a timer and a message event, have no guaranteed order. The only guarantee is order within one queue.
Node.js runs microtasks at different moments than a browser. Its docs describe phases: timers runs expired setTimeout and setInterval callbacks, poll retrieves I/O events, and check runs setImmediate callbacks. After each callback Node drains the process.nextTick queue and then the microtask queue. In a CommonJS main script the nextTick queue runs first, so nextTick runs before a promise job. In an ES module the module is already being evaluated inside a microtask drain, so the promise job runs first. Ordering of setTimeout(fn, 0) against setImmediate in the main module depends on process performance, so it is not guaranteed. Phase detail belongs in the Node.js topic.
// run as CommonJS (.cjs)
setTimeout(() => console.log('timer'), 0);
Promise.resolve().then(() => console.log('promise job'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
// → sync
// → nextTick
// → promise job
// → timer
Real-world scenario
A page freezes for a second while it processes a large array in one loop. The click handlers and the render are tasks stuck behind that one task. The fix is to split the work into chunks and yield to the loop between them, for example by queueing the next chunk with setTimeout, so input tasks can run in between. A microtask is not a yield: it runs before the loop moves on, so chunking with queueMicrotask or await of an already-resolved promise freezes the page just the same.
Trade-offs
Run-to-completion makes code easy to reason about: between two statements no other JavaScript can change your variables, so you do not need locks. The price is that you cannot preempt a long task, so latency-sensitive work has to be written cooperatively, by chunking work or moving it to a worker.