Blog

How the JS Event Loop Works

I'm excited to break it down for you. We'll talk about the event loop, Web APIs, queues, and more. No heavy theory, just simple stories and code you can try. Grab your coffee, open Chrome console, and let's dive in! ☕

Why JavaScript Feels Async (But Isn't Multithreaded)

JavaScript runs in one main thread. Imagine a single worker at a conveyor belt. It can only do one job at a time: push code onto a call stack, run it, pop it off when done.

But for slow stuff like timers (setTimeout), network calls (fetch), or user clicks, the browser helps out. These go to Web APIs – outside JavaScript's world. The worker says, "Hey browser, handle this timer and call me back later." Browser does it, puts the callback in a queue. Worker finishes other jobs, then checks the queue.

Result? Non-blocking! JS seems fast and async, but it's all planned with event loop picking tasks when ready.

console.log('Start');

setTimeout(() => {
  console.log('Timer done');
}, 0);

console.log('End');

Output:

Start
End
Timer done

See? "Timer done" waits, even with 0ms. Why? Event loop magic!

Call Stack and Heap: JS's Memory Homes

First, basics.

  • Call Stack: Like a stack of plates. Functions push on top (LIFO - Last In, First Out). Run sync code here.

    [main()]
      [foo()]
        [bar()]  <- top, running now
  • Heap: Big storage for objects, arrays, variables that live longer. Stack points to heap.

When stack empty, event loop looks for queued tasks.

Simple example:

function greet() {
  console.log('Hello!');
}

greet(); // Pushes greet() on stack, runs, pops.

Stack grows/shrinks fast. Heap holds strings like 'Hello!'.

Web APIs: Browser's Helpers

JavaScript core doesn't have timers or DOM. Browser provides Web APIs:

  • setTimeout, setInterval
  • DOM events: addEventListener('click', ...)
  • fetch, XMLHttpRequest
  • requestAnimationFrame

These run outside JS thread. Example:

setTimeout(callback, 1000); // Browser timer starts, JS continues.
document.addEventListener('click', callback); // Browser watches clicks.
fetch('/api').then(callback); // Browser does network.

When done, browser puts callback in task queue (macrotask queue).

Task Queue and Event Loop: The Heartbeat

Task Queue (or Callback Queue, Macrotask Queue): FIFO line of callbacks from Web APIs.

Event Loop: Endless loop:

  1. Check call stack. Empty?
  2. Run all microtasks (promises etc.).
  3. Pick one macrotask from queue, push to stack.
  4. Repeat.

ASCII art time! 🚀

┌─────────────────┐
│   Web APIs      │  setTimeout --> timer expires --> callback to queue
│  (Browser)      │
└─────────┬───────┘

          v
┌─────────────────┐    ┌──────────────┐
│   Callback      │<---│ Event Loop   │ <--- constantly checks
│   Queue         │    │  (Pump)      │
│ (Macrotasks)    │    └──────┬───────┘
└─────────────────┘           │
                              │ Stack empty? Microtasks first!
                              v
                       ┌──────────────┐
                       │   Call Stack │  <- executes tasks one by one
                       │   (LIFO)     │
                       └──────────────┘

                             v
                        Heap (objects)

Event loop "pumps" queues into stack.

Microtasks vs Macrotasks: Priority Drama

Not all queues equal!

  • Macrotasks (Task Queue): setTimeout, setInterval, DOM events, fetch callbacks. One per loop cycle.

  • Microtasks Queue (higher priority): Promise .then(), queueMicrotask(), MutationObserver. All run before next macrotask.

Why? Promises super fast, need priority.

Example – mind blown! 🤯

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

Output:

1
4
3  <- microtask first!
2  <- macrotask after

Another: nested!

setTimeout(() => {
  console.log('Timeout');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise 1');
  Promise.resolve().then(() => console.log('Promise 2'));
});

console.log('End');
End
Promise 1
Promise 2  <- all micros before timeout
Timeout

Rule: Event loop drains entire microtask queue before macrotask.

ASCII for queues:

Event Loop Cycle:
1. Stack: empty
2. Microqueue: [P1, P2] --> run all --> empty
3. Macroqueue: [Timeout] --> take one --> run
4. New micros? Drain again...

Real-World Pitfall: Blocking the Loop

Too much sync work? Starving queues! UI freezes.

// Bad: long loop blocks stack
function heavy() {
  let i = 0;
  while (i < 1e9) i++; // 1 billion iterations
}
setTimeout(() => alert('Too late!'), 0);
heavy(); // Blocks everything

Fix: chunk it with setTimeout.

function heavyChunk(start, end, cb) {
  let i = start;
  while (i < end && i < start + 1e6) i++; // Smaller chunks
  if (i < end) {
    setTimeout(() => heavyChunk(i, end, cb), 0);
  } else {
    cb();
  }
}

Visualizing with Diagrams

Simple flow:

Sync Code --> Call Stack

Web API Callback --> Macrotask Queue ──\
Promise Callback ──> Microtask Queue ───┐
                                         │ Event Loop picks
                                         │ Micro first, then Macro

For promises chain:

Main --> Stack empty
       |
       v
Micro1 (then1) --> pushes then2 to Micro
       |
       v Micro empty --> Macro (timeout)

Using Chrome DevTools to See It Live 🛠️

Best way to learn: watch it!

  1. Console Profiling: Open DevTools (F12) > Console.

    console.profile('Event Loop');
    // Your code here
    console.profileEnd();

    Then Performance tab to see.

  2. Performance Tab (Gold!):

    • Go Performance > Record (circle button).
    • Run your async code.
    • Stop. Zoom into timeline.
    • See Task bars (macrotasks), Scripting for stack.
    • FP (Forced Reflow) shows blocking.
  3. Sources > Call Stack: During breakpoints, see stack live.

  4. Console Tricks:

    // Simulate loop
    setTimeout(() => console.log('macro'), 0);
    queueMicrotask(() => console.log('micro'));

    Paste and watch order!

  5. Event Listeners: Elements tab > select element > Event Listeners. See callbacks queued.

Pro tip: Use JS Bin or CodePen with console open.

JS Event Loop Thumbnail

(Imagine a cool diagram here – stack, queues, loop arrow. You can draw it too!)

Common Gotchas and Tips

  1. setTimeout(0) not instant: Yields to browser (repaints).
  2. Promise chain blocks macrotasks: All .then() micros run first.
  3. Avoid sync loops: Use requestIdleCallback for background.
  4. Node.js similar: But process.nextTick > promises.
  5. Debug: Add console.trace() in callbacks to see stack.

Test yourself:

setTimeout(() => console.log('1'), 0);
Promise.resolve().then(() => {
  console.log('2');
  setTimeout(() => console.log('3'), 0);
});
queueMicrotask(() => console.log('4'));

Answer: 2, 4, 1, 3

Wrap Up: Master the Loop, Master JS!

Wow, we covered a lot! Event loop makes JS powerful for web. Remember: single thread, but smart queues. Practice with console, DevTools. Next time page lags, think "stack blocked?"

Share your examples in comments. What confuses you most? Hit like if helped. Happy coding, friends! 🚀

More reads:

How is this guide?