The JavaScript event loop: tasks, microtasks and rendering
Why does a promise callback run before setTimeout(fn, 0), and why does one slow handler freeze the whole page?
One thread runs one task to completion, then drains every queued microtask, then the browser may render a frame. Promises and await continuations are microtasks, timers and events are tasks, so microtasks always cut the line and nothing at all happens while your code is on the stack.
THE MENTAL MODEL
Picture a single cashier (the call stack) serving a line of customers (tasks: a timer firing, a click, a network response, a postMessage). The cashier never interrupts a customer. When one finishes, the cashier first clears a priority tray of small follow-ups (microtasks: promise reactions, await continuations, queueMicrotask, MutationObserver), including any follow-ups those follow-ups add, and only then looks up. Roughly once per display frame the browser gets a turn to run requestAnimationFrame callbacks, recalculate style and layout, and paint. Everything you observe follows from this: setTimeout(fn, 0) waits behind the whole microtask tray, a recursive promise chain can starve rendering forever, and a 300 ms loop in a click handler means 300 ms where no input is handled and no pixel changes. Async does not mean parallel; it means later, on the same thread.
HOW IT FITS TOGETHER
- Pick one taskThe browser chooses a task queue (timers, input, networking, messaging) and takes its oldest runnable task. Queue choice is up to the browser, which is how input can be prioritized over timers.
- Run it to completionThe callback runs on the call stack until the stack is empty. Nothing preempts it: no other handler, no rendering, no input processing.
- Microtask checkpointDrain the entire microtask queue: promise reactions, await continuations, queueMicrotask, MutationObserver. Microtasks queued during the drain run in the same drain.
- Rendering opportunity?Usually aligned to the display refresh (about 16.7 ms at 60 Hz, less on 120 Hz screens). Skipped when nothing changed or the page is hidden.
- requestAnimationFrameRun all callbacks registered before this frame started, with one shared timestamp. Callbacks registered now wait for the next frame. Microtasks drain after each one.
- Style, layout, paintAlso resize, scroll, ResizeObserver and IntersectionObserver steps. Then the frame is handed to the compositor.
- Idle time, then repeatIf time remains before the next frame and no task is waiting, requestIdleCallback callbacks may run. Then back to picking a task.
- Call stack → Consolelog("A")
- Call stack → Task queue (timers)setTimeout(B, 0): timer startedThe task is queued when the timer expires, at the earliest after this script task ends.
- Call stack → Microtask queuePromise.resolve().then(C): job queuedThe promise is already fulfilled, so the reaction job is queued immediately. It still never runs synchronously.
- Call stack → Microtask queuequeueMicrotask(D)
- Call stack → Consoleasync fn body runs sync: log("E")An async function runs synchronously up to its first await.
- Call stack → Microtask queueawait null: continuation F queuedAwaiting a non-promise still suspends and resumes in a microtask.
- Call stack → Consolelog("G"); script task ends, stack empty
- Microtask queue → Call stackCheckpoint: run C, then D, then FFIFO. Anything these callbacks queue also runs before the timer.
- Call stack → Consolelog C, D, F
- Task queue (timers) → Call stackNext task: timer callback BPossibly after a rendering step. Nested timers are clamped to at least 4 ms after 5 levels.
- Call stack → Consolelog("B")
KEY TERMS
- Call stack and run-to-completion
- Each callback runs until it returns and the stack is empty; nothing interleaves with it. This is why shared state needs no locks within one thread, and also why any synchronous work blocks input, timers and rendering for its full duration.
- Tasks (macrotasks)
- Units of work the event loop picks one at a time: running a script, a timer callback, a dispatched input event, a network or message callback. The HTML spec has several task queues and lets the browser choose which to service, so ordering is only guaranteed within one source (two timers keep their order relative to each other).
- Microtasks
- Promise reactions (then/catch/finally), await continuations, queueMicrotask and MutationObserver callbacks. The queue is drained completely whenever the stack empties after a callback, and microtasks queued during the drain join the same drain. That is why they always beat setTimeout(fn, 0).
- Rendering steps and requestAnimationFrame
- Rendering is not a task you schedule; the browser takes a rendering opportunity between tasks, roughly once per display refresh. rAF callbacks run inside it, just before style and layout, which makes rAF the right place for visual updates. It is paused in background tabs and hidden iframes.
- Long tasks and responsiveness
- Any task over 50 ms is a long task. An input event that arrives during one waits until it ends, then waits for its own handler and the next paint; that total is what Interaction to Next Paint measures. The fix is to split work and yield between chunks, or move it off the main thread.
- Yielding: setTimeout vs scheduler.yield()
- Awaiting a promise does not yield to the browser: the continuation is a microtask. setTimeout(resolve, 0) does yield, but your continuation goes to the back of the task queue. scheduler.yield() yields and resumes in a prioritized continuation; it ships in Chromium (129+) and Firefox (142+) but not Safari, so feature-detect it.
- Node.js in one paragraph
- Node uses the same task/microtask split on top of libuv phases (timers, poll, check, close). It adds process.nextTick, a queue drained before promise microtasks in CommonJS, and setImmediate, which runs in the check phase. Phase details belong to the Node runtime explainer.
IN YOUR STACK
JavaScript · Any runtime: ordering puzzle Client + server
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => {
console.log("C");
queueMicrotask(() => console.log("C2")); // queued during the drain
});
queueMicrotask(() => console.log("D"));
(async () => {
console.log("E"); // runs synchronously, up to the first await
await null; // continuation becomes a microtask
console.log("F");
})();
console.log("G");
// A E G C D F C2 B- Synchronous code first (A, E, G), then microtasks in FIFO order (C, D, F), then microtasks those queued (C2), and only then the next task (B).
- Same output in browsers, Node.js, Deno and Bun: this part of the ordering is defined by ECMAScript jobs plus the host's microtask checkpoint.
- Do not build logic on these orderings across unrelated code. If B must run after C, chain it explicitly.
JavaScript · Browser: scheduler.yield() Client
function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0)); // Safari fallback
}
async function processInChunks(items, handle, budgetMs = 10) {
let deadline = performance.now() + budgetMs;
for (const item of items) {
handle(item);
if (performance.now() >= deadline) {
await yieldToMain(); // input, rendering and other tasks can run here
deadline = performance.now() + budgetMs;
}
}
}
const list = document.querySelector("#list");
const status = document.querySelector("#status");
const rows = Array.from({ length: 50_000 }, (_, i) => `Row ${i}`);
const renderRow = (text) => list.append(Object.assign(document.createElement("li"), { textContent: text }));
document.querySelector("#load").addEventListener("click", async () => {
status.textContent = "Loading…";
await yieldToMain(); // let the feedback paint before the heavy work
await processInChunks(rows, renderRow);
status.textContent = "Done";
});- await Promise.resolve() is not a yield: it is a microtask, so the browser still cannot render or handle input in between.
- scheduler.yield() resumes ahead of other queued tasks, so your work is not starved by unrelated timers; setTimeout puts it at the back of the queue. MDN lists it as not Baseline: Chromium 129+ and Firefox 142+, no Safari yet.
- Chunking keeps the page responsive but does not make the work faster. For CPU-heavy work that does not touch the DOM, use a Web Worker; for very long lists, virtualize instead of rendering 50,000 nodes.
JavaScript · async/await vs .then() Client + server
// What you write
async function loadUser(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// Roughly what it means: each await splits the function into a continuation
function loadUserThen(id) {
return fetch(`/api/users/${id}`).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`); // becomes a rejection
return res.json();
});
}
// Either way, the caller keeps running first
loadUser(1).then((user) => console.log("user", user));
console.log("requested"); // logs before "user"
// Sequential awaits in a loop serialize independent requests
async function loadMany(ids) {
return Promise.all(ids.map((id) => loadUser(id))); // concurrent, same thread
}- An async function runs synchronously until its first await and always returns a promise; a throw anywhere inside becomes a rejection, never a synchronous exception.
- await always suspends, even on a non-promise or an already-resolved promise: the rest of the function resumes in a microtask after the currently queued ones.
- Promise.all overlaps the waiting, not the computation. CPU work in each continuation still runs one at a time on the same thread.
JavaScript · Browser: requestAnimationFrame Client
const box = document.querySelector("#box");
const DURATION_MS = 600;
let start;
let frameId = 0;
function step(now) {
start ??= now;
// Progress from elapsed time, not frame count: 60 Hz and 144 Hz screens agree.
const t = Math.min((now - start) / DURATION_MS, 1);
const eased = 1 - (1 - t) ** 3; // ease-out cubic
box.style.transform = `translateX(${eased * 300}px)`; // write only, no layout reads
if (t < 1) frameId = requestAnimationFrame(step); // rAF is one-shot
}
function play() {
cancelAnimationFrame(frameId); // restart cleanly if already running
start = undefined;
frameId = requestAnimationFrame(step);
}
document.querySelector("#play").addEventListener("click", play);- rAF callbacks run just before style, layout and paint, so a change made there lands in the very next frame; a setTimeout(16) loop drifts and can update twice or zero times per frame.
- Reading layout (offsetWidth, getBoundingClientRect) after writing styles in the same frame forces a synchronous layout. Batch reads first, then writes.
- rAF stops in background tabs. Never use it to drive non-visual logic such as polling or game simulation that must keep time.
JavaScript · Node.js: nextTick vs promises Server
// order.cjs (CommonJS) run: node order.cjs
const fs = require("node:fs");
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("sync");
// sync, nextTick, promise, then timeout and immediate in either order
fs.readFile(__filename, () => {
// Inside an I/O callback the order is deterministic:
setTimeout(() => console.log("io: timeout"), 0);
setImmediate(() => console.log("io: immediate")); // always first
});- In CommonJS the nextTick queue drains before promise microtasks. In an ES module the order flips (promise, then nextTick) because module evaluation already runs inside the microtask drain.
- Outside I/O callbacks, setTimeout(0) versus setImmediate depends on process timing. Inside one, setImmediate (check phase) always wins.
- Prefer queueMicrotask over process.nextTick in new code: it behaves the same across runtimes. Recursive nextTick starves I/O exactly like recursive microtasks starve rendering.
WHERE IT BITES
- Starving the loop with microtasks: a promise chain or queueMicrotask that keeps re-queueing itself never lets a task or a frame through. The page freezes even though every step was async.
- Treating await as a yield. Awaiting resolved promises in a hot loop keeps the main thread busy just like synchronous code; only a task boundary (setTimeout, scheduler.yield, a message) lets input and rendering in.
- Assuming setTimeout(fn, 0) means now. It means after the current task, all microtasks, possibly a frame, and other queued tasks; nested timers are clamped to 4 ms and background tabs throttle to 1 s or more.
- Heavy synchronous work in event handlers (big JSON.parse, sorting, rendering thousands of rows). One 400 ms click handler is a 400 ms INP no matter how fast the network was.
- Driving logic from requestAnimationFrame. It pauses in hidden tabs, so timers, sync and analytics built on it silently stop.
- Interleaving DOM reads and writes inside one frame. Each read after a write forces layout, turning one frame into dozens of layout passes.
CLOSE THE AI. EXPLAIN THIS.
Without running it, predict the output of: log 1; setTimeout(log 2); Promise.resolve().then(() => { log 3; setTimeout(log 4) }); queueMicrotask(log 5); log 6. Then explain where a frame could be painted and why a click arriving mid-script waits.WHEN IT BREAKS IN PRODUCTION
SOURCES
- WHATWG HTML: Event loops (opens in new tab)Checked
- MDN: Using microtasks in JavaScript with queueMicrotask() (opens in new tab)Checked
- MDN: JavaScript execution model (opens in new tab)Checked
- MDN: Scheduler.yield() (opens in new tab)Checked
- web.dev: Optimize long tasks (opens in new tab)Checked
- Node.js: The event loop, timers, and process.nextTick() (opens in new tab)Checked
Explainer reviewed