JavaScript Event Loop Cheat Sheet
Covers the call stack, the task and microtask queues, and how the event loop orders callbacks from Promises, setTimeout, and other async APIs.
Call Stack & Synchronous Execution
JavaScript is single-threaded: one call stack.
function first() { second(); }function second() { third(); }function third() { console.log("deepest"); }first();// Stack grows: first -> second -> third// Each frame pops off as its function returns// JS is single-threaded: nothing else runs until the stack is empty
Execution Order: Sync, Microtask, Macrotask
Sync code always runs before any queued callback.
console.log("1: sync start");setTimeout(() => console.log("4: macrotask (setTimeout)"), 0);Promise.resolve().then(() => console.log("3: microtask (promise)"));console.log("2: sync end");// Output order: 1, 2, 3, 4// All sync code runs first, then ALL queued microtasks,// then one macrotask, then microtasks again, and so on.
Microtasks Drain Before the Next Macrotask
The microtask queue always empties completely first.
setTimeout(() => console.log("timeout"), 0);Promise.resolve() .then(() => console.log("promise 1")) .then(() => console.log("promise 2")); // Chained .then adds another microtaskqueueMicrotask(() => console.log("explicit microtask"));// Output: "promise 1", "explicit microtask", "promise 2", "timeout"// The ENTIRE microtask queue empties before the event loop// picks up the next macrotask (the setTimeout callback)
Blocking the Event Loop
Long synchronous work freezes everything else.
function blockFor(ms) { const end = Date.now() + ms; while (Date.now() < end) {} // Busy-wait -- freezes everything}console.log("start");setTimeout(() => console.log("this is delayed"), 0);blockFor(3000); // No callbacks, renders, or input can run for 3sconsole.log("end");// "this is delayed" only logs AFTER blockFor finishes,// even though the timer was 0ms
Key Concepts
Core vocabulary for the event loop.
- Call stack- LIFO structure tracking currently executing function frames, single-threaded
- Task queue (macrotasks)- Holds callbacks from setTimeout, setInterval, I/O, and UI events
- Microtask queue- Holds Promise .then/.catch/.finally callbacks and queueMicrotask(); higher priority than macrotasks
- Event loop- Continuously checks: if the stack is empty, run all microtasks, then one macrotask, repeat
- setTimeout(fn, 0)- Doesn't run immediately -- it queues fn as a macrotask after the current stack and all microtasks clear
- requestAnimationFrame- Schedules a callback before the next repaint, separate from both queues above
Node.js Event Loop Phases
Node's loop is split into ordered phases, unlike the simpler browser model.
// Node phases, in order, each phase drains its own FIFO queue:// timers -> pending callbacks -> idle/prepare -> poll -> check -> close callbackssetTimeout(() => console.log("timer"), 0);setImmediate(() => console.log("immediate"));// Inside a plain script the order is NOT guaranteed (timer resolution jitter),// but inside an I/O callback, setImmediate ALWAYS wins:const fs = require("fs");fs.readFile(__filename, () => { setTimeout(() => console.log("timeout in I/O"), 0); setImmediate(() => console.log("immediate in I/O")); // Output: "immediate in I/O" then "timeout in I/O" -- the poll phase // moves straight to check (setImmediate) before looping back to timers});
process.nextTick vs Promise Microtasks (Node)
nextTick has its own queue that drains before the Promise microtask queue.
Promise.resolve().then(() => console.log("promise microtask"));process.nextTick(() => console.log("nextTick"));queueMicrotask(() => console.log("queueMicrotask"));// Output: "nextTick", "promise microtask", "queueMicrotask"// process.nextTick's queue is fully drained first, then the microtask// queue (Promises + queueMicrotask, in registration order) runs.// Recursive process.nextTick calls can starve I/O entirely -- avoid// unbounded recursive nextTick scheduling in hot paths.
What async/await Really Compiles To
Every await is a microtask boundary, even for an already-resolved value.
async function example() { console.log("a"); await null; // suspends here -- resumes as a NEW microtask console.log("b");}console.log("start");example();console.log("end");// Output: "start", "a", "end", "b"// Roughly equivalent to:// function example() {// console.log("a");// return Promise.resolve(null).then(() => console.log("b"));// }// Each `await`, even on a non-Promise, schedules a microtask --// awaiting N sequential values costs N microtask round-trips.
Microtask Starvation
A self-perpetuating microtask chain can block rendering and macrotasks indefinitely.
// DANGEROUS: this never lets the event loop reach a macrotask or repaintfunction spin() { Promise.resolve().then(spin); // reschedules itself forever as a microtask}// spin(); // would freeze setTimeout callbacks, clicks, and rendering// SAFE alternative: yield to the macrotask queue periodicallyfunction chunkedWork(items, i = 0) { const end = Math.min(i + 1000, items.length); for (; i < end; i++) processItem(items[i]); if (i < items.length) { setTimeout(() => chunkedWork(items, i), 0); // yields to rendering/input }}function processItem() {}
Advanced Scheduling APIs
Lesser-known primitives for controlling when work runs.
- queueMicrotask(fn)- Schedules fn on the microtask queue directly, without the overhead of a Promise wrapper
- requestIdleCallback(fn)- Runs fn during a browser idle period, with a deadline object; not available in Node
- MessageChannel- A postMessage-based trick historically used to implement a true 0-delay macrotask (faster than setTimeout's clamped minimum)
- setImmediate(fn)- Node-only: runs fn in the 'check' phase, immediately after the current poll phase completes
- Atomics.wait- Blocks a worker thread synchronously waiting on shared memory; never use on the main thread
- scheduler.postTask(fn, {priority})- Modern browser API for prioritized task scheduling (user-blocking/user-visible/background)
A Promise chain can starve macrotasks (and even freeze the UI) if each .then() schedules another microtask indefinitely, since the loop won't move to setTimeout callbacks or rendering until the microtask queue is fully empty.