The Event Loop Is Really a Collection of Checkpoints
A practical model for tasks, microtasks, rendering, and the ordering surprises that appear in production JavaScript.
Most explanations of the JavaScript event loop begin with a queue and a call stack. That picture is useful, but it becomes misleading once an application mixes timers, promises, DOM events, and rendering. A better working model is a sequence of checkpoints at which the host gets an opportunity to move work between queues.
JavaScript itself defines jobs for features such as promises. The browser supplies tasks, rendering opportunities, network events, and several other pieces of scheduling machinery. Node.js supplies a different host loop with its own phases. The language and the host cooperate, which is why memorising one diagram rarely answers every ordering question.
Tasks establish the outer rhythm
A browser task begins when the event loop selects work such as an initial script, a timer callback, or an input event. The selected callback runs to completion: another task cannot interrupt it halfway through. If that callback performs ten million synchronous operations, a click handler already waiting in the task queue must wait.
button.addEventListener('click', () => {
expensiveSynchronousWork();
updateStatus('finished');
});
The DOM update is observable to later JavaScript immediately, but the user will generally not see a paint until control returns to the browser. “Run to completion” therefore describes JavaScript execution, not a guarantee that the pixels are updated after every statement.
Timers also join this outer scheduling rhythm. A delay of zero does not mean “run now”; it means the callback becomes eligible after the minimum delay. It still waits for current work, previously queued tasks, and any scheduling constraints imposed by the browser.
Microtasks close out a task
Promise reactions and callbacks registered with queueMicrotask enter the microtask queue. Once the current JavaScript stack is empty, the runtime performs a microtask checkpoint and drains that queue before selecting the next task.
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => {
console.log('C');
queueMicrotask(() => console.log('D'));
});
console.log('E');
The output is A, E, C, D, then B. The promise callback runs before the timer, and the microtask it schedules is added to the same checkpoint. The queue is drained until it is empty, not merely copied once and processed as a fixed batch.
This creates a starvation hazard. A microtask that continually schedules another microtask can delay timers and input indefinitely. Browsers protect responsiveness in several ways, including yielding to rendering between individual microtasks when a checkpoint grows beyond the frame budget, but an application should not depend on that safeguard. If a loop needs to share time with the UI, schedule a task or use an API designed around rendering rather than recursively queuing microtasks.
Similar APIs do not fail identically
At first glance, these two fragments appear equivalent:
queueMicrotask(() => {
throw new Error('broken');
});
Promise.resolve().then(() => {
throw new Error('also broken');
});
Both callbacks run in the microtask queue. Their error reporting differs, however. An exception thrown from queueMicrotask becomes a rejected promise associated with the current checkpoint, so it is normally surfaced through the browser’s unhandledrejection event. An exception in a promise reaction is reported as an ordinary uncaught exception because the reaction job has no caller to receive it. This difference can matter when error telemetry subscribes to only one global event.
The two APIs also communicate intent. queueMicrotask says that no promise value is involved; .then says that the callback participates in a promise chain. Choosing the one that matches the abstraction makes error handling and return values easier to reason about.
Rendering is an opportunity, not a queue item
After a task and its microtask checkpoint, the browser may update rendering. It does not have to paint after every task, especially when the page is in the background or the browser decides that no visual update is needed.
requestAnimationFrame callbacks are tied to those rendering opportunities. They are a good place to calculate and apply visual changes for the upcoming frame. A common pattern is to collect many input events and perform one visual update in the next animation callback.
let latestX = 0;
let scheduled = false;
window.addEventListener('pointermove', (event) => {
latestX = event.clientX;
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => {
marker.style.transform = `translateX(${latestX}px)`;
scheduled = false;
});
}
});
This limits DOM work without assuming that pointer events arrive at a particular frequency.
Node.js adds another priority level
The broad distinction between synchronous work, microtasks, and later tasks is still useful in Node.js, but it is not a browser loop transplanted onto a server. Timers, polling, checks, and close callbacks occupy different phases.
Node also exposes process.nextTick. A nextTick callback joins the normal promise microtask queue and is processed in insertion order with .then and queueMicrotask callbacks. That makes it portable to think of all three as equivalent priority, although process.nextTick remains Node-specific syntax.
When ordering is important, the most reliable tool is a small experiment in the actual target runtime. Write down the predicted order first, run the code, and then explain every line. The event loop becomes much less mysterious when treated as a set of explicit scheduling boundaries rather than a single conveyor belt.