Technology · JavaScript
AdvancedJavaScript Event Loop
Learn how the call stack, microtask queue, and task queue control JavaScript execution order.
TL;DR
- 01The call stack runs synchronous code one frame at a time.
- 02Microtasks always run before the next macrotask executes.
- 03Promise callbacks resolve before setTimeout, even with zero delay.
The Call Stack
- The call stack tracks function calls in progress, adding a frame each time a function runs.
function a() { b(); } function b() { console.log("in b"); } a(); // pushes a, then b, then pops each as they return - JavaScript runs on a single thread, so only one stack frame executes at any given moment.
// No two functions run literally at the same time on the main thread - Synchronous code runs to completion before the engine looks at any queued callback.
console.log("1"); console.log("2"); console.log("3"); // always logs in this order, no interleaving - A stack overflow happens when recursive calls keep pushing frames without returning.
function recurse() { return recurse(); } // recurse(); // throws RangeError: Maximum call stack size exceeded - The event loop only starts processing queues once the call stack is completely empty.
setTimeout(() => console.log("timer"), 0); console.log("sync"); // logs "sync" first, "timer" only after stack clears
Macrotasks and the Task Queue
- Macrotasks include callbacks from
setTimeout,setInterval, DOM events, and I/O completion.setTimeout(() => console.log("macrotask"), 0); console.log("sync code runs first"); - The event loop runs one macrotask per iteration, then checks the microtask queue before the next one.
setTimeout(() => console.log("task 1"), 0); setTimeout(() => console.log("task 2"), 0); // task 1 and task 2 each get their own full event loop turn setTimeout(fn, 0)schedules a macrotask with an effective minimum delay, not true zero delay.setTimeout(() => console.log("later"), 0); console.log("now"); // logs "now" before "later" every time- Each macrotask can trigger new rendering, since the browser may paint between task queue turns.
// The browser can repaint the screen after a macrotask finishes - Nested timers below a certain depth get clamped to a minimum delay, often around 4ms in browsers.
function tick() { setTimeout(tick, 0); // browser clamps repeated nested timers }
Microtasks and Promises
- Promise
.then(),.catch(), and.finally()callbacks all run as microtasks.Promise.resolve().then(() => console.log("microtask")); console.log("sync"); // logs "sync", then "microtask" - Use
queueMicrotask()to schedule a callback directly on the microtask queue without a Promise.queueMicrotask(() => console.log("runs before next render or timer")); - The microtask queue fully drains before the event loop moves to the next macrotask.
Promise.resolve().then(() => console.log("micro 1")); Promise.resolve().then(() => console.log("micro 2")); setTimeout(() => console.log("macro"), 0); // logs: micro 1, micro 2, macro - A microtask that schedules another microtask still runs before the next macrotask.
Promise.resolve().then(() => { Promise.resolve().then(() => console.log("nested microtask")); }); setTimeout(() => console.log("timer"), 0); // "nested microtask" logs before "timer" awaitinside an async function suspends execution and resumes as a microtask once the awaited value settles.async function run() { console.log("start"); await null; console.log("resumed as microtask"); } run(); console.log("after call"); // logs: start, after call, resumed as microtask
setTimeout vs Promise Ordering
- Promise callbacks always run before a
setTimeoutcallback scheduled in the same synchronous block.setTimeout(() => console.log("timeout"), 0); Promise.resolve().then(() => console.log("promise")); // logs: promise, timeout - This holds true even if the
setTimeoutdelay is shorter than the time the microtask queue takes to drain.setTimeout(() => console.log("timeout"), 0); for (let i = 0; i < 3; i++) { Promise.resolve().then(() => console.log(`micro ${i}`)); } // all three micro logs print before "timeout" - Mixing timers and promises in a loop reveals the ordering clearly.
console.log("1"); setTimeout(() => console.log("2"), 0); Promise.resolve().then(() => console.log("3")); console.log("4"); // logs: 1, 4, 3, 2 requestAnimationFrameruns before the next repaint, separate from both the microtask and macrotask queues.requestAnimationFrame(() => console.log("before paint")); setTimeout(() => console.log("macrotask"), 0);- Async/await ordering follows the same microtask rules as explicit
.then()chains.async function asyncFn() { console.log("async"); } asyncFn(); setTimeout(() => console.log("timeout"), 0); Promise.resolve().then(() => console.log("promise")); // logs: async, promise, timeout
Why the UI Doesn't Block
- The browser interleaves rendering with macrotasks, repainting the screen between queue turns.
// Each setTimeout callback gives the browser a chance to render afterward - Breaking a long loop into smaller
setTimeout-scheduled chunks lets the UI stay responsive.function processChunk(items, index = 0) { const end = Math.min(index + 100, items.length); for (let i = index; i < end; i++) { /* work */ } if (end < items.length) { setTimeout(() => processChunk(items, end), 0); } } requestIdleCallbackschedules work during browser idle periods, avoiding interference with rendering.requestIdleCallback(() => { // low-priority work that can wait for idle time });- Web Workers run JavaScript on a separate thread, keeping heavy computation off the main thread entirely.
const worker = new Worker("heavy-task.js"); worker.postMessage(data); worker.onmessage = (e) => console.log(e.data); - A single long synchronous function still blocks everything, since async APIs only help between tasks, not during one.
function blockEverything() { const start = Date.now(); while (Date.now() - start < 5000) {} // freezes the page for 5 seconds }
Tips
- 01Use queueMicrotask() when you need code to run after the current synchronous block but before any rendering or timers.
- 02Break long synchronous loops into chunks with setTimeout so the browser gets a chance to render between batches.
- 03Reach for Promise chains over deeply nested setTimeout calls, since microtasks resolve predictably before the next macrotask.
Warnings
- 01A long-running synchronous function blocks the call stack entirely, freezing rendering and user input until it finishes.
- 02Chaining many Promise.then() calls can starve macrotasks, since the microtask queue must fully drain before the next one runs.
- 03setTimeout(fn, 0) does not run immediately — it waits for the call stack and queued microtasks to finish first.
FAQ
- The event loop is the mechanism that lets JavaScript handle asynchronous work despite running on a single thread. It continuously checks whether the call stack is empty, then pulls the next task from a queue to run. This is how callbacks, promises, and timers eventually execute without blocking the main thread forever.
- Promise callbacks go into the microtask queue, while setTimeout callbacks go into the macrotask (task) queue. After each macrotask, the event loop fully drains the microtask queue before picking up the next macrotask. Since the current script itself is a macrotask, any queued microtasks run before the next setTimeout, regardless of its delay.
- The task queue (macrotask queue) holds callbacks from setTimeout, setInterval, and I/O events. The microtask queue holds Promise callbacks and queueMicrotask() callbacks. The event loop always empties the entire microtask queue between each macrotask, giving microtasks higher priority.
- JavaScript runs on a single thread, so a long synchronous function occupies the call stack completely. The event loop cannot process rendering updates, user clicks, or queued callbacks until that function returns. Break the work into smaller chunks using setTimeout, requestIdleCallback, or a Web Worker to keep the page responsive.
- No, it schedules the callback as a macrotask with a minimum delay, not zero actual delay. The browser also enforces a minimum delay, often around 4ms for nested timers. The callback only runs after the current call stack finishes and the microtask queue is fully drained.