Back to JavaScript tutorials
Advanced15 min read

The Event Loop

Understand how JavaScript schedules asynchronous work through the call stack, task queues, and microtasks.

Call Stack and Single Threading

JavaScript runs user code on a single call stack per thread. Functions push frames when called and pop when they return. Long-running synchronous code blocks the stack and freezes the UI in browsers or delays I/O in Node.js.

Offload heavy computation to Web Workers in browsers or worker threads in Node.js. Never perform expensive synchronous loops on the main thread in interactive applications.

  • One call stack per thread
  • Sync code blocks everything on that thread
  • Use workers for CPU-heavy work
function a() { b(); }
function b() { console.log('done'); }
a(); // a → b → log → pop → pop

Macrotasks and the Callback Queue

When async APIs like `setTimeout`, `setInterval`, I/O callbacks, and UI events complete, their callbacks enter macrotask queues. The event loop picks the next macrotask only when the call stack is empty and microtasks are drained.

This is why `setTimeout(fn, 0)` does not run immediately — it waits until current synchronous code and all pending microtasks finish. Understanding this ordering prevents race conditions in initialization code.

  • Macrotasks: timers, I/O, DOM events
  • Processed after stack is empty and microtasks done
  • `setTimeout(0)` is not instant
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3'); // 1, 3, 2

Microtasks and Priority

Microtasks include Promise callbacks (`.then`, `.catch`, `.finally`), `queueMicrotask`, and `MutationObserver` handlers. After each macrotask, the engine drains the entire microtask queue before taking the next macrotask.

This priority explains why `Promise.resolve().then(...)` runs before `setTimeout` even when both are scheduled during the same synchronous block. Microtask starvation can occur if microtasks recursively schedule more microtasks without yielding.

  • Microtasks run before next macrotask
  • Promises schedule microtasks
  • Avoid infinite microtask loops
Promise.resolve().then(() => console.log('micro'));
setTimeout(() => console.log('macro'), 0);

Browser vs Node.js Event Loops

Browsers integrate the event loop with rendering pipelines — `requestAnimationFrame` callbacks run before paint. Node.js has multiple phases in its libuv loop: timers, pending callbacks, poll, check, and close callbacks, plus separate microtask processing between phases.

Node 11+ processes microtasks between macrotasks similarly to browsers, but phase ordering still matters for `setImmediate` vs `setTimeout` nuances. Read the Node.js docs for your target version when debugging server-side timing issues.

  • Browsers tie loop to rendering
  • Node has libuv phases
  • Microtask timing differs slightly by runtime

Debugging Async Ordering

When logs appear out of expected order, map each async operation to macrotask vs microtask. Use `performance.mark` and async stack traces in DevTools to follow scheduling.

Design APIs that do not depend on subtle ordering between Promises and timers unless documented. Prefer explicit `await` chains in application logic over relying on queue priority for correctness.

  • Classify operations as micro vs macro task
  • Use DevTools async stacks
  • Prefer explicit await over timing hacks

Get In Touch


Ready to discuss your next project? Drop me a message.