JavaScriptintermediate

Event Loop & Call Stack Explained

Understand how JavaScript executes code with the event loop, call stack, microtasks, and macrotasks. Master async execution order, setTimeout behavior, and debugging strategies.

14 min readยทPublished Mar 4, 2026
event-loopcall-stackasyncjavascript

The Single-Threaded Problem

JavaScript runs on a single thread. One call stack. One thing at a time. This sounds like a severe limitation โ€” how does a language with one thread handle user clicks, network requests, timers, and animations simultaneously?

The answer: it doesn't do them simultaneously. It orchestrates them through the event loop โ€” a coordination mechanism that makes single-threaded JavaScript feel concurrent.

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                   JavaScript Runtime                   โ”‚
โ”‚                                                        โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚  Call Stack   โ”‚   โ”‚  Web APIs / Node APIs         โ”‚  โ”‚
โ”‚  โ”‚              โ”‚   โ”‚                              โ”‚  โ”‚
โ”‚  โ”‚  main()      โ”‚   โ”‚  setTimeout()                โ”‚  โ”‚
โ”‚  โ”‚  fn1()       โ”‚   โ”‚  fetch()                     โ”‚  โ”‚
โ”‚  โ”‚  fn2()       โ”‚   โ”‚  addEventListener()          โ”‚  โ”‚
โ”‚  โ”‚              โ”‚   โ”‚  requestAnimationFrame()     โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ”‚         โ”‚                      โ”‚                       โ”‚
โ”‚         โ”‚    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”‚
โ”‚         โ”‚    โ”‚         Task Queues                 โ”‚    โ”‚
โ”‚         โ”‚    โ”‚                                    โ”‚    โ”‚
โ”‚         โ”‚    โ”‚  Microtask Queue: [p1, p2, ...]    โ”‚    โ”‚
โ”‚         โ”‚    โ”‚  Macrotask Queue: [t1, t2, ...]    โ”‚    โ”‚
โ”‚         โ”‚    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ”‚
โ”‚         โ”‚                      โ”‚                       โ”‚
โ”‚         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Event Loop โ”€โ”€โ”˜                       โ”‚
โ”‚                  (coordinator)                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Call Stack

The call stack tracks function execution. When you call a function, it's pushed onto the stack. When it returns, it's popped off.

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  const result = square(n);
  console.log(result);
}

printSquare(4);
Step-by-step call stack:

1. printSquare(4)             โ”‚ printSquare โ”‚
                              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

2. square(4)                  โ”‚ square      โ”‚
                              โ”‚ printSquare โ”‚
                              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

3. multiply(4, 4)            โ”‚ multiply    โ”‚
                              โ”‚ square      โ”‚
                              โ”‚ printSquare โ”‚
                              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

4. multiply returns 16        โ”‚ square      โ”‚
                              โ”‚ printSquare โ”‚
                              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

5. square returns 16          โ”‚ printSquare โ”‚
                              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

6. console.log(16)            โ”‚ console.log โ”‚
                              โ”‚ printSquare โ”‚
                              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

7. console.log returns        โ”‚ printSquare โ”‚
                              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

8. printSquare returns        โ”‚             โ”‚  (empty)
                              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Stack Overflow

The stack has a size limit. Recursive functions without a base case will exceed it:

function infinite() {
  return infinite(); // no base case
}

infinite();
// RangeError: Maximum call stack size exceeded

Chrome's V8 has roughly 10,000-15,000 frames. The exact limit depends on the frame size (how many local variables each function has).

// Measure your stack limit
function measureStack(count = 0) {
  try {
    return measureStack(count + 1);
  } catch {
    return count;
  }
}

console.log(measureStack()); // ~10,000-15,000 depending on engine

Web APIs and the Callback System

When JavaScript encounters an async operation (setTimeout, fetch, addEventListener), it delegates it to the browser's Web APIs (or Node.js C++ APIs). These run outside the JS thread. When the operation completes, a callback is placed in a queue.

console.log('Start');

setTimeout(() => {
  console.log('Timer callback');
}, 2000);

console.log('End');
Timeline:

1. console.log('Start')        โ†’ Call Stack: [log]    โ†’ prints "Start"
2. setTimeout(cb, 2000)        โ†’ Call Stack: [setTimeout]
                                  Web API starts 2s timer
3. console.log('End')          โ†’ Call Stack: [log]    โ†’ prints "End"
4. (call stack is empty)
   ... 2 seconds pass ...
5. Timer done โ†’ cb moves to    โ†’ Macrotask Queue: [cb]
6. Event loop picks up cb      โ†’ Call Stack: [cb]     โ†’ prints "Timer callback"

Output:
Start
End
Timer callback

The key: setTimeout doesn't run the callback after 2 seconds. It places the callback in the queue after 2 seconds. The callback runs when the stack is empty and the event loop picks it up.

The Event Loop Algorithm

The event loop follows a specific algorithm on each "tick":

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                   Event Loop Cycle                         โ”‚
โ”‚                                                           โ”‚
โ”‚  1. Execute all synchronous code on the call stack        โ”‚
โ”‚     โ”‚                                                     โ”‚
โ”‚     โ–ผ                                                     โ”‚
โ”‚  2. Call stack empty? โ”€โ”€โ”€โ”€ No โ”€โ”€โ–บ keep executing          โ”‚
โ”‚     โ”‚                                                     โ”‚
โ”‚     Yes                                                   โ”‚
โ”‚     โ”‚                                                     โ”‚
โ”‚     โ–ผ                                                     โ”‚
โ”‚  3. Process ALL microtasks (drain the microtask queue)    โ”‚
โ”‚     โ”‚                                                     โ”‚
โ”‚     โ–ผ                                                     โ”‚
โ”‚  4. Microtask queue empty?                                โ”‚
โ”‚     โ”‚                                                     โ”‚
โ”‚     Yes                                                   โ”‚
โ”‚     โ”‚                                                     โ”‚
โ”‚     โ–ผ                                                     โ”‚
โ”‚  5. [If rendering needed] requestAnimationFrame callbacks โ”‚
โ”‚     โ”‚                                                     โ”‚
โ”‚     โ–ผ                                                     โ”‚
โ”‚  6. [If rendering needed] Render / paint                  โ”‚
โ”‚     โ”‚                                                     โ”‚
โ”‚     โ–ผ                                                     โ”‚
โ”‚  7. Pick ONE macrotask from the queue                     โ”‚
โ”‚     โ”‚                                                     โ”‚
โ”‚     โ–ผ                                                     โ”‚
โ”‚  8. Go to step 1                                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The critical detail: all microtasks are processed before the next macrotask. And microtasks can enqueue more microtasks, which are also processed before moving on.

Macrotasks vs Microtasks

Two types of queues with different priorities:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Microtasks (high priority)โ”‚ Macrotasks (normal priority)  โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Promise .then/.catch      โ”‚ setTimeout                    โ”‚
โ”‚ queueMicrotask()          โ”‚ setInterval                   โ”‚
โ”‚ MutationObserver          โ”‚ setImmediate (Node.js)        โ”‚
โ”‚ process.nextTick (Node)   โ”‚ I/O callbacks                 โ”‚
โ”‚                           โ”‚ UI rendering events           โ”‚
โ”‚                           โ”‚ requestAnimationFrame*        โ”‚
โ”‚                           โ”‚ MessageChannel                โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

* rAF has its own timing โ€” runs before paint, after microtasks

Execution Order Demo

console.log('1 - sync');

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

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

queueMicrotask(() => console.log('4 - microtask (queueMicrotask)'));

console.log('5 - sync');

// Output:
// 1 - sync
// 5 - sync
// 3 - microtask (Promise)
// 4 - microtask (queueMicrotask)
// 2 - macrotask (setTimeout)

Step by step:

  1. console.log('1') โ€” sync, runs immediately
  2. setTimeout โ€” registers macrotask, moves on
  3. Promise.then โ€” registers microtask, moves on
  4. queueMicrotask โ€” registers microtask, moves on
  5. console.log('5') โ€” sync, runs immediately
  6. Stack empty โ†’ drain microtask queue โ†’ logs 3, then 4
  7. Pick next macrotask โ†’ logs 2

Microtasks Spawning Microtasks

Microtasks enqueued during microtask processing are processed in the same cycle:

console.log('1');

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

Promise.resolve()
  .then(() => {
    console.log('3 - micro');
    // This microtask is processed BEFORE the setTimeout
    return Promise.resolve();
  })
  .then(() => {
    console.log('4 - micro (chained)');
    queueMicrotask(() => console.log('5 - micro (nested)'));
  });

console.log('6');

// Output:
// 1
// 6
// 3 - micro
// 4 - micro (chained)
// 5 - micro (nested)
// 2 - macro

All microtasks (3, 4, 5) run before the macrotask (2), even though 4 and 5 were enqueued by earlier microtasks.

Warning: Infinite Microtask Loop

If microtasks keep spawning microtasks, the macrotask queue (and rendering) is starved:

// DON'T DO THIS โ€” browser will freeze
function infiniteMicro() {
  queueMicrotask(infiniteMicro);
}
infiniteMicro(); // UI never updates, setTimeout never fires

setTimeout(fn, 0) Behavior

setTimeout(fn, 0) doesn't mean "run immediately." It means "run as soon as the stack is clear and any microtasks are done, via the macrotask queue."

console.log('A');

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

console.log('C');

// Output: A, C, B
// Even with 0ms delay, B waits for the macrotask queue

Minimum Delay

Browsers enforce a minimum delay of ~4ms for nested setTimeout calls (after 5 consecutive immediate timeouts). This is per the HTML spec.

let start = Date.now();
let count = 0;

function measure() {
  count++;
  if (count <= 10) {
    setTimeout(() => {
      console.log(`${count}: ${Date.now() - start}ms`);
      measure();
    }, 0);
  }
}

measure();
// First few: ~0-1ms
// After 5th: ~4ms each (browser throttling kicks in)

setTimeout for Yielding

Use setTimeout(fn, 0) to break up long-running tasks and let the browser breathe:

// BAD โ€” blocks the main thread, UI freezes
function processLargeArray(items) {
  for (const item of items) {
    heavyComputation(item); // 1000 items ร— 10ms = 10s freeze
  }
}

// GOOD โ€” process in chunks, yielding between them
async function processLargeArray(items, chunkSize = 50) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);

    for (const item of chunk) {
      heavyComputation(item);
    }

    // Yield to the event loop โ€” let rendering and events happen
    await new Promise((resolve) => setTimeout(resolve, 0));
  }
}

Promise Microtask Priority

Promises always resolve via the microtask queue. This gives them higher priority than timers.

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

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

console.log('sync');

// Output:
// sync
// promise 1
// promise 2
// timeout 1
// timeout 2

A more complex example:

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2() {
  console.log('async2');
}

console.log('script start');

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

async1();

new Promise((resolve) => {
  console.log('promise1');
  resolve();
}).then(() => {
  console.log('promise2');
});

console.log('script end');

Let's trace this step by step:

Call Stack Execution:
1. log('script start')        โ†’ output: "script start"
2. setTimeout(cb, 0)          โ†’ registers macrotask
3. async1()                   โ†’ enters async1
4. log('async1 start')        โ†’ output: "async1 start"
5. await async2()             โ†’ calls async2()
6. log('async2')              โ†’ output: "async2"
7. await pauses async1        โ†’ remainder queued as microtask
8. new Promise executor runs
9. log('promise1')            โ†’ output: "promise1"
10. resolve()                 โ†’ .then callback queued as microtask
11. log('script end')         โ†’ output: "script end"

Microtask Queue Processing:
12. async1 resumes
13. log('async1 end')         โ†’ output: "async1 end"
14. Promise .then runs
15. log('promise2')           โ†’ output: "promise2"

Macrotask Queue Processing:
16. setTimeout callback runs
17. log('setTimeout')         โ†’ output: "setTimeout"

Final output:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

requestAnimationFrame Placement

requestAnimationFrame (rAF) runs before the browser paints โ€” after microtasks, before the next macrotask. It's synchronized with the display refresh rate (usually 60fps = ~16.67ms).

console.log('1 - sync');

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

requestAnimationFrame(() => console.log('3 - rAF'));

Promise.resolve().then(() => console.log('4 - microtask'));

console.log('5 - sync');

// Typical output:
// 1 - sync
// 5 - sync
// 4 - microtask
// 3 - rAF         โ† before paint, after microtasks
// 2 - macrotask   โ† after paint

Note: the exact ordering of rAF vs setTimeout can vary between browsers and frames. The reliable guarantee is: microtasks first, then rAF (before paint), then macrotasks.

One Event Loop Cycle (rendering frame):

โ”‚ Execute sync code โ”‚
โ”‚                   โ”‚
โ–ผ                   โ”‚
โ”‚ Drain microtasks  โ”‚
โ”‚                   โ”‚
โ–ผ                   โ”‚
โ”‚ rAF callbacks     โ”‚ โ† runs before paint
โ”‚                   โ”‚
โ–ผ                   โ”‚
โ”‚ Layout + Paint    โ”‚ โ† browser renders
โ”‚                   โ”‚
โ–ผ                   โ”‚
โ”‚ One macrotask     โ”‚ โ† setTimeout, setInterval, etc.
โ”‚                   โ”‚
โ–ผ                   โ”‚
โ”‚ (next cycle)      โ”‚

Using rAF for Smooth Animations

function animate(element) {
  let position = 0;

  function step() {
    position += 2;
    element.style.transform = `translateX(${position}px)`;

    if (position < 300) {
      requestAnimationFrame(step); // schedule next frame
    }
  }

  requestAnimationFrame(step);
}

rAF vs setTimeout for Animation

setTimeout(fn, 16):
  - Not synced with display refresh
  - Can cause janky animations (frame drops)
  - Runs even when tab is hidden (wastes CPU)

requestAnimationFrame(fn):
  - Synced with display refresh rate
  - Smooth 60fps animations
  - Automatically pauses when tab is hidden
  - Browser can batch and optimize

Node.js Event Loop Differences

Node.js has a more granular event loop with specific phases:

   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”Œโ”€โ–บโ”‚         timers             โ”‚ โ† setTimeout, setInterval
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  โ”‚     pending callbacks      โ”‚ โ† I/O errors, etc.
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  โ”‚       idle, prepare        โ”‚ โ† internal use
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  โ”‚          poll              โ”‚ โ† I/O callbacks
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  โ”‚          check             โ”‚ โ† setImmediate
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  โ”‚       close callbacks      โ”‚ โ† socket.on('close')
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚                โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Microtasks (Promise, process.nextTick) run between EVERY phase.
process.nextTick has even higher priority than Promise microtasks.

setImmediate vs setTimeout(0) in Node.js

// In Node.js โ€” order is non-deterministic in the main module:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Could be either order

// But inside an I/O callback, setImmediate always runs first:
const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// Always: immediate, timeout

process.nextTick

process.nextTick is Node-specific and runs before Promise microtasks:

Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));

// Output:
// nextTick
// promise

Debugging Event Loop Issues

Identifying Main Thread Blocking

// Simple main thread monitor
let lastCheck = Date.now();

setInterval(() => {
  const now = Date.now();
  const delay = now - lastCheck;

  if (delay > 50) { // interval is ~16ms, >50ms means blocking
    console.warn(`Main thread blocked for ${delay}ms`);
  }

  lastCheck = now;
}, 16);

Using Performance API

// Mark and measure async operations
performance.mark('fetch-start');

const data = await fetch('/api/data');

performance.mark('fetch-end');
performance.measure('fetch-duration', 'fetch-start', 'fetch-end');

const measure = performance.getEntriesByName('fetch-duration')[0];
console.log(`Fetch took ${measure.duration}ms`);

Long Task API

// Detect tasks that block the main thread for >50ms
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn(`Long task detected: ${entry.duration}ms`);
    console.log('Task details:', entry);
  }
});

observer.observe({ entryTypes: ['longtask'] });

Chrome DevTools Performance Tab

  1. Open DevTools โ†’ Performance tab
  2. Click Record
  3. Interact with your page
  4. Stop recording
  5. Look for:
    • Long yellow bars โ€” JavaScript execution blocking the thread
    • Red triangles โ€” dropped frames
    • Task durations โ€” anything > 50ms is a "long task"

Common Patterns

Yielding to the Event Loop

Modern approach using scheduler.yield() (where available) or fallback:

async function yieldToMain() {
  if ('scheduler' in globalThis && 'yield' in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

async function processItems(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);

    // Yield every 10 items to keep UI responsive
    if (i % 10 === 0) {
      await yieldToMain();
    }
  }
}

Batching DOM Updates

// BAD โ€” forces layout recalculation between reads and writes
elements.forEach((el) => {
  const height = el.offsetHeight;   // read (forces layout)
  el.style.height = height * 2 + 'px'; // write (invalidates layout)
});
// Each iteration: read โ†’ layout โ†’ write โ†’ invalidate
// N elements = N forced layouts

// GOOD โ€” batch reads, then batch writes
const heights = elements.map((el) => el.offsetHeight); // all reads

requestAnimationFrame(() => {
  elements.forEach((el, i) => {
    el.style.height = heights[i] * 2 + 'px'; // all writes
  });
});
// One layout calculation total

Idle Callback for Non-Critical Work

function doNonCriticalWork(deadline) {
  while (deadline.timeRemaining() > 0 && workQueue.length > 0) {
    const task = workQueue.shift();
    task();
  }

  if (workQueue.length > 0) {
    requestIdleCallback(doNonCriticalWork);
  }
}

// Queue non-critical tasks
const workQueue = [];

function scheduleWork(task) {
  workQueue.push(task);

  if (workQueue.length === 1) {
    requestIdleCallback(doNonCriticalWork);
  }
}

// Usage
scheduleWork(() => analytics.track('page_view'));
scheduleWork(() => prefetchNextPage());
scheduleWork(() => lazyLoadImages());

Quiz: Predict the Output

Challenge 1

console.log('A');

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

new Promise((resolve) => {
  console.log('C');
  resolve();
}).then(() => {
  console.log('D');
});

console.log('E');

Answer: A, C, E, D, B

  • A: sync
  • C: Promise executor is sync
  • E: sync
  • D: microtask (Promise.then)
  • B: macrotask (setTimeout)

Challenge 2

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

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

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

Answer: 3, 6, 5, 1, 2, 4

  • Microtasks first: 3, then 6 (both first-level .then), then 5 (chained .then from 3)
  • Macrotasks in order: 1, 2, 4 (4 was scheduled last, during microtask processing)

Challenge 3

async function foo() {
  console.log('foo start');
  await bar();
  console.log('foo end');
}

async function bar() {
  console.log('bar');
}

console.log('start');
foo();
console.log('end');

Answer: start, foo start, bar, end, foo end

  • start: sync
  • foo start: sync (inside foo, before await)
  • bar: sync (bar executes synchronously)
  • end: sync (foo is paused at await)
  • foo end: microtask (resumes after await)

Key Takeaways

  1. JavaScript is single-threaded โ€” the event loop makes it feel concurrent
  2. Call stack executes sync code โ€” one frame at a time, LIFO order
  3. Microtasks before macrotasks โ€” Promises always run before setTimeout
  4. ALL microtasks drain before next macrotask โ€” including microtasks spawned by microtasks
  5. setTimeout(fn, 0) is not immediate โ€” it waits for the stack to clear and microtasks to drain
  6. rAF runs before paint โ€” use it for animations, not setTimeout
  7. Long tasks block everything โ€” break up work, yield to the main thread
  8. Node.js has more phases โ€” timers, poll, check, each with microtask checkpoints
  9. process.nextTick > Promise โ€” in Node.js, nextTick has higher priority than Promise microtasks
  10. Use DevTools Performance tab โ€” the single best tool for diagnosing event loop issues

Found this helpful?

Support devsofus โ€” help us keep creating free dev guides.

Related Articles