Web Platform · JavaScript Runtime

Task Sources and the Ordering Guarantees of the Event Loop

Why the browser runs callbacks from several independent queues, what ordering the specification actually promises, and where cross-queue assumptions break.

A page that does nothing visible is still running code. A click handler fires, a timer callback runs, a promise reaction resumes a suspended function, a network response arrives, a rendering update recomputes layout. All of this happens on one thread with one call stack, and the browser has to decide, repeatedly and at high frequency, which of the many pending callbacks runs next. The answer is not “the oldest callback overall”, because there is no single queue of callbacks. The HTML standard describes a set of task queues, each associated with a task source, and leaves the choice among them to the implementation.

That design decision explains most of the ordering behaviour programmers observe: why a setTimeout with zero delay does not run before a message posted to a MessageChannel, why two timers scheduled in sequence always run in sequence, and why an event fired from one subsystem cannot be assumed to arrive before an event fired from another. The same loop then hands control to the microtask machinery described in the microtask checkpoint, which drains between tasks rather than alongside them.

One loop, many queues

The standard models a task queue as a set of tasks served in the order they were queued, and groups queues by the source that produced them. Timers form one source, DOM manipulation another, networking another, user interaction another, and posted messages yet another. Each queue is drained in the order tasks were enqueued, so within one source the model is strictly first-in-first-out.

Across sources there is no promised order. The event loop processing model says, in effect: choose a task queue that has a runnable task, take its oldest runnable task, and run it. Which queue is chosen is implementation-defined, constrained only by weak fairness expectations — an implementation that permanently ignored one source would starve it, so browsers rotate. The consequence is that a test which compares the arrival order of a timer callback and a network callback is testing the browser’s current scheduling heuristic, not a contract.

Two tasks from the same source, however, are a contract. Two setTimeout calls with the same delay run in registration order even if the second is scheduled microseconds later, because the timer source is a single FIFO queue and both tasks were enqueued in that order. This is why sequencing work by chaining timers is reliable while sequencing it by racing a timer against a postMessage is not.

What becomes a task

The distinction that matters in practice is task versus microtask, not task versus “synchronous code”. A task is a unit the loop can run to completion before considering anything else. The following are tasks in the HTML model:

  • a timer callback from setTimeout or setInterval;
  • a message event delivered to a MessagePort or a worker;
  • a DOM event dispatched by user interaction, and the propagation of that event through the tree;
  • a networking task, such as the load event of an image;
  • history traversal and certain storage completion callbacks.

A promise reaction is not a task. Neither is queueMicrotask, nor a MutationObserver callback, nor the resumption of an async function after an await. Those run at a microtask checkpoint, which the loop performs after each task completes and whenever the JavaScript stack empties. The separation is what makes await compose predictably: all pending promise work finishes before the browser returns to its queue of tasks.

A single turn, step by step

For a page with a rendering opportunity, one iteration of the loop looks like this:

  1. Select a task queue with a runnable task and take its oldest task.
  2. Run the task’s callback. The stack must be empty when the callback returns; a task that leaves unfinished asynchronous work has only started the work.
  3. Perform a microtask checkpoint: drain the microtask queue until it is empty, including microtasks enqueued by microtasks.
  4. If this is a rendering opportunity, run the update-the-rendering steps: resize and scroll steps, requestAnimationFrame callbacks, style and layout, paint.
  5. Repeat.

Step 4 does not occur after every task. Rendering is tied to the display refresh, so at 60 Hz a frame comes roughly every 16.7 ms, while a busy page may execute dozens of tasks in that window. Several tasks can therefore run between two rendering updates, and a task that changes the DOM does not force layout immediately — layout happens at the next rendering opportunity, which is why reading a layout property after a write forces the browser to run layout synchronously in the middle of the task.

Step 3 has a subtlety worth stating: the checkpoint runs whenever the stack is empty, including inside nested event loop invocations such as alert or a synchronous XMLHttpRequest draining its own tasks. A microtask scheduled from a microtask runs in the same checkpoint, which is the mechanism behind both correct promise chaining and the starvation failure described later.

const channel = new MessageChannel();
channel.port1.onmessage = () => console.log('message task');

setTimeout(() => console.log('timer task'), 0);
channel.port2.postMessage(null);

Promise.resolve().then(() => console.log('microtask'));
console.log('sync');

// Guaranteed: 'sync' and 'microtask' precede both tasks.
// The relative order of the timer and message tasks is not guaranteed
// by the standard; it depends on the queue the loop selects first.

When a task is only half the story

Splitting work across tasks is the standard way to keep a page responsive, but the granularity is coarser than it looks. Each task pays for a full round trip through the loop: the loop must finish the current task, drain microtasks, possibly render, and only then pick the next queue entry. A thousand tasks with a trivial body each cost far more in scheduling overhead than one task doing a thousand iterations, because the boundary between tasks is also a boundary where the browser may insert layout, paint, and input handling.

This is the reason requestIdleCallback, and the scheduler.postTask API where it is available, exist: they expose the loop’s own notion of “am I about to render” and “how much time is left in this frame” instead of forcing code to approximate it with timers. The approximation is poor, because timers are subject to clamping. A setTimeout nested more than five levels deep is clamped to a minimum of 4 ms, and background tabs throttle timers far more aggressively, on the order of once per second. A scheduler.postTask callback carries a priority hint and is not clamped the same way.

Starvation and the fairness that is not specified

Because selection among queues is unspecified, a source that always has a runnable task can crowd out the others. A MessageChannel used as a fast setTimeout substitute, ping-ponging a message back to itself, keeps the posted-message source permanently non-empty. Browsers rotate queues to prevent total starvation, but the practical effect is still a timer callback delayed by many milliseconds while the message source monopolises turns.

The harder starvation case is inside a single turn. Microtasks drain completely before the loop continues, so a promise chain that enqueues another promise from each reaction never yields. The page freezes, input is not processed, and no rendering occurs — the loop never reaches step 1 again. The symptom is identical to an infinite while loop, which is why recursively scheduling promise work to process a large dataset is a mistake; incrementing a counter and yielding to a task every few thousand items is not.

Where the model stops applying

The model is per execution thread, not per page. A dedicated worker has its own event loop and its own task queues, so ordering inside a worker is independent of ordering on the window that created it; the two threads communicate only through message tasks on each side. Documents in separate processes — typically different origins, or same-origin documents the browser has isolated — likewise have separate loops, and nothing orders a callback in one against a callback in the other. Even within one origin, an event dispatched synchronously by dispatchEvent is not a task at all: it runs on the current stack, and its listeners run before any queued task.

Node.js borrows the vocabulary but implements a different structure, with named phases for timers, pending callbacks, polling, and check callbacks. Code that depends on cross-source ordering is therefore portable only by accident. The portable subset is small and sufficient: same-source FIFO, microtasks before the next task, and rendering between tasks rather than within them.

References

  1. HTML Living Standard — Event loops — WHATWG
  2. ECMAScript Language Specification — Jobs and Host Operations — Ecma International