Michael Ouroumis logoichael Ouroumis

Understanding JavaScript Event Loop and Call Stack: A Visual Walkthrough

Diagram of JavaScript event loop showing call stack, task queue, and microtask queue

TL;DR: JavaScript’s event loop orchestrates your code by pushing frames onto the call stack, processing macrotasks (e.g., setTimeout) and microtasks (e.g., promises) in the right order. Visualize each tick: inspect the call stack, see which queue pops next, and learn why promise callbacks always run before timers. Master this to write non-blocking, high-performance async code.


Why Understanding the Event Loop & Call Stack Matters

Even β€œsimple” JavaScript apps juggle callbacks, timers, and promises. Without a clear mental model of the call stack, event loop, and task queues, you’ll run into:

  • Unexpected ordering in callbacks
  • UI jank from long tasks monopolizing the stack
  • Promise gotchas where microtasks fire before I/O callbacks
  • Hard-to-debug race conditions in async code

Grasping this runtime core leads to snappier front-ends and rock-solid back-end services.


1. Call Stack Basics

The call stack is a LIFO structure that tracks function execution:

Call Stack Frame: β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ main() β”‚ ← top of stack β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ foo() β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ bar() β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  • Push: When you invoke a function, JS pushes a new frame.
  • Pop: When the function returns, it’s popped off.
  • Blocking: Heavy work here blocks the event loop until the stack is empty.
function bar() { console.log('bar') } function foo() { bar() console.log('foo') } console.log('start') foo() console.log('end') // Execution order: // 1. start // 2. bar // 3. foo // 4. end

2. Event Loop & Task Queues

JavaScript runs in a loop:

  1. Execute all code on the call stack.
  2. Check microtask queue (promises, process.nextTick).
  3. If microtasks exist, drain them before moving on.
  4. Else, pick the next macrotask (timers, I/O, UI events).
  5. Repeat.
[ START OF TICK ] β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 1. Call Stack: run script β”‚ β”‚ 2. Microtask Queue: [] β”‚ β”‚ 3. Macrotask Queue: [setTimeout cb] β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β†’ run script β†’ drain microtasks β†’ run macrotask

3. Microtasks vs. Macrotasks

Queue TypeExamplesPriority
MicrotaskPromise.then, queueMicrotaskπŸ”Ί Highest (after each stack)
MacrotasksetTimeout, setInterval, DOM eventsπŸ”» Lower
console.log('A') setTimeout(() => console.log('B'), 0) Promise.resolve().then(() => console.log('C')) console.log('D') // Output order: // A β†’ D β†’ C β†’ B
  1. A, D run on call stack
  2. C runs in microtask queue
  3. B runs in next macrotask

4. Visual Walkthrough: Step-by-Step

Let’s simulate a tick with code:

console.log('start') // [1] Promise.resolve().then(() => { // [2] console.log('promise callback') // [5] }) setTimeout(() => { // [3] console.log('timeout callback') // [7] }, 0) console.log('end') // [4]

Tick 1:

Call Stack: [global β†’ console.log('start')] Output: start Call Stack cleared

Tick 2 (same execution turn):

Call Stack: [global β†’ Promise.resolve().then()] Schedules microtask Call Stack cleared

Tick 3:

Call Stack: [global β†’ setTimeout(...)] Schedules macrotask Call Stack cleared

Tick 4:

Call Stack: [global β†’ console.log('end')] Output: end Call Stack cleared

Drain Microtasks:

Microtask Queue: [promise callback] Call Stack: [global β†’ promise callback] Output: promise callback Stack cleared

Next Macrotask:

Macrotask Queue: [timeout callback] Call Stack: [global β†’ timeout callback] Output: timeout callback Stack cleared

Final console:

start
end
promise callback
timeout callback

5. Best Practices for Async Code

  • Keep tasks short: Offload heavy loops to Web Workers or chunk work via setTimeout(…, 0).
  • Prefer Promises & async/await: Clear microtask behavior leads to predictable ordering.
  • Mind long chains: Use .catch() or try/catch around await to avoid unhandled rejections.
  • Visualize: Sketch your event loop with arrows between call stack and queues for tricky flows.

Final Takeaways

  • The call stack is your synchronous workhorse.
  • The event loop coordinates macrotasks and microtasks.
  • Microtasks always drain before the next timer.
  • Visualizing state per tick prevents β€œwhy did this run first?” headaches.

Armed with this mental model and visual walkthroughs, you’ll write JavaScript that’s not just correct, but highly performant and easy to debug. Happy coding!

Enjoyed this post? Share it: