This chapter follows Execution contexts and the call stack. That chapter described one stack; this one is about what decides what goes on it next. The browser rules come from the HTML Standard’s event loop processing model, and the Node rules from Node’s libuv-based loop.
Tasks and task sources
A task is a unit of work the event loop runs from start to finish: evaluating a <script>, dispatching a click, running a setTimeout callback, delivering a message from a worker, processing a network response. Each task comes from a task source — timers, user interaction, networking, DOM manipulation, and others.
The HTML Standard gives each event loop several task queues, and tasks from one source always go into the same queue in order. The browser chooses which queue to take the next task from. That freedom is how browsers prioritize: Chrome, for example, can run a pending input event before a pending timer, even though the timer was queued first. Within one source, order is preserved; across sources, it is not guaranteed.
Every task runs to completion. Once a task’s callback starts, nothing else runs on that thread until the call stack is empty again. No other task, no rendering, no input handling. This is what makes JavaScript code easy to reason about — no other code changes your variables halfway through a function — and it is also why a slow task freezes the page.
One turn of the loop
A browser event loop repeats these steps:
flowchart TB
pick["Pick the oldest task from one task queue"] --> run["Run it until the call stack is empty"]
run --> micro["Run every microtask, including new ones"]
micro --> render{"Time to render?"}
render -->|"yes"| frame["Update the rendering: rAF callbacks, style, layout, paint"]
render -->|"no"| idle
frame --> idle["If idle: requestIdleCallback callbacks"]
idle --> pick- Pick a task and run it.
- Perform a microtask checkpoint: run microtasks until the microtask queue is empty.
- If this is a rendering opportunity — roughly once per display refresh, about every 16.7 ms on a 60 Hz screen, and only if something changed — update the rendering. This runs
requestAnimationFramecallbacks, then style calculation, layout, and paint. - If there is time left before the next frame and nothing is queued, run
requestIdleCallbackcallbacks.
The order explains most observable behavior. Rendering only happens between tasks, so everything a task changes reaches the screen together, after the task is done:
button.textContent = 'Working…';runHeavyCalculation(); // takes two secondsbutton.textContent = 'Done';The user never sees “Working…”. Both assignments happen in one task, and the browser only paints after it. Breaking up long tasks shows how to let the first update reach the screen.
Microtasks
A microtask is a smaller job that runs as soon as the current task, or the current callback, finishes — before the loop moves on. Microtasks come from:
- Promise reactions: callbacks passed to
then,catch, andfinally. - The continuation of an async function after
await. queueMicrotask(callback).MutationObservercallbacks in browsers.
The checkpoint runs microtasks until the queue is empty, including microtasks queued by other microtasks. That rule predicts the output of the usual ordering puzzles:
console.log('script start');
setTimeout(() => console.log('timeout'), 0);
queueMicrotask(() => console.log('microtask'));
(async () => { console.log('async start'); await null; console.log('after await');})();
Promise.resolve() .then(() => console.log('then 1')) .then(() => console.log('then 2'));
console.log('script end');script startasync startscript endmicrotaskafter awaitthen 1then 2timeoutStep through it:
- The script itself is a task. It logs
script start, queues the timer as a new task, and queuesmicrotask. - The async function runs synchronously until its first
await, soasync startprints now.await nullqueues its continuation as a microtask and suspends. Promise.resolve().then(...)queuesthen 1.then 2is not queued yet, because its promise has not settled.script endprints, and the script task is over.- The checkpoint runs
microtask,after await, andthen 1. Runningthen 1settles the next promise, which queuesthen 2, and the checkpoint runs that too. - Only now is the queue empty, so the loop takes the next task: the timer.
The same rule has a sharp edge. A microtask that always queues another one never lets the checkpoint finish:
function spin() { queueMicrotask(spin);}spin(); // the page never renders or handles input againA recursive setTimeout would let rendering and input run between iterations. A recursive microtask does not, because it never gives control back to the loop.
Microtasks after each callback
The checkpoint does not run only after tasks. It also runs whenever a callback finishes and the call stack becomes empty. The difference shows when one task calls several callbacks:
const button = document.querySelector('button');
button.addEventListener('click', () => { Promise.resolve().then(() => console.log('microtask 1')); console.log('listener 1');});
button.addEventListener('click', () => { Promise.resolve().then(() => console.log('microtask 2')); console.log('listener 2');});When the user clicks, the browser calls each listener from its own code, and the stack is empty after each one:
listener 1microtask 1listener 2microtask 2When a script calls button.click(), both listeners run while that script is still on the stack, so no checkpoint happens until the script finishes:
listener 1listener 2microtask 1microtask 2Code that relies on a microtask running between two listeners therefore behaves differently in tests that dispatch events from scripts than for real users.
Rendering and requestAnimationFrame
requestAnimationFrame(callback) runs the callback in the next rendering step, just before style and layout. It is the right place for visual updates, because changes made there are painted in the same frame, and it is not called at all while the tab is hidden.
function animate(bar, startTime, duration) { requestAnimationFrame((now) => { const progress = Math.min((now - startTime) / duration, 1); bar.style.transform = `scaleX(${progress})`; if (progress < 1) animate(bar, startTime, duration); });}
animate(document.querySelector('.bar'), performance.now(), 500);A setTimeout loop for the same animation runs on its own schedule. Some frames get two updates and some none, which shows as stutter.
Layout normally runs once per frame, in the rendering step. Reading a geometry property such as offsetHeight or getBoundingClientRect() after changing styles forces the browser to run style and layout right away, inside your task:
for (const item of items) { item.style.width = `${container.offsetWidth / 2}px`; // read, write, read, write…}Each iteration reads layout after the previous write invalidated it, so the browser lays out the page once per item. Reading once before the loop, then writing, needs one layout.
Timers are minimum delays
setTimeout(callback, delay) queues a task after at least delay milliseconds. It never runs sooner, and it often runs later:
- The thread must be free. A timer that is due while another task runs waits for that task, and for the microtasks after it.
- Nesting is clamped. Once timers have been nested five levels deep — a timer callback that sets a timer, and so on — the HTML Standard raises any delay below 4 ms to 4 ms.
- Hidden tabs are throttled. Browsers run timers in background tabs at most about once per second, and Chrome throttles long-hidden pages further.
- Other queues may go first. The browser can prefer input or rendering over the timer queue.
setTimeout(fn, 0) therefore means “as a new task, after whatever is already pending”, not “now”. For “after the current code but before anything else”, use queueMicrotask. For “before the next paint”, use requestAnimationFrame. For timing measurements, use performance.now() instead of counting on timer precision.
Node’s event loop
Node runs the same model of tasks and microtasks, but its loop comes from libuv and is organized in phases. Each turn visits them in order, running the callbacks queued for each:
| Phase | Runs |
|---|---|
| Timers | Expired setTimeout and setInterval callbacks |
| Pending callbacks | Some deferred system-level I/O callbacks |
| Poll | Waits for I/O and runs most I/O callbacks |
| Check | setImmediate callbacks |
| Close callbacks | close events, such as a socket’s |
Node also has a queue of its own that runs before promise microtasks: process.nextTick. After every single callback, Node drains the nextTick queue first, then the promise microtask queue. Since Node 11, that happens between individual timer and immediate callbacks too, matching browsers.
const fs = require('node:fs');
setTimeout(() => console.log('timeout'));setImmediate(() => console.log('immediate'));process.nextTick(() => console.log('nextTick'));Promise.resolve().then(() => console.log('promise'));
fs.readFile(__filename, () => { setTimeout(() => console.log('io: timeout')); setImmediate(() => console.log('io: immediate'));});A run on Node 24 printed:
nextTickpromisetimeoutimmediateio: immediateio: timeoutnextTick and promise come first because they drain before the loop starts. The order of timeout and immediate in the main module is not guaranteed: it depends on whether the 1 ms timer has expired by the time the loop reaches the timers phase. Inside an I/O callback, the order is fixed. The loop is in the poll phase, so the check phase, with setImmediate, comes before the next timers phase.
A process.nextTick that queues another process.nextTick starves I/O exactly like a recursive microtask starves rendering. Prefer queueMicrotask or setImmediate unless you specifically need to run before promise reactions. Event Loop in NodeJS covers the loop from the Node side, with EventEmitter examples.
Next: Breaking up long tasks.
Check your understanding
Question 1 of 7
0 of 7 checked