Skip to main content
โณintermediate

The Event Loop

The event loop is the mechanism that allows JavaScript to be non-blocking despite running on a single thread. Understand the call stack, task queues, and microtask queue.

JavaScript is Single-Threaded

JavaScript executes code on a single thread โ€” one operation at a time. There's no parallel execution (without Web Workers). Yet it handles thousands of concurrent operations like network requests and user events. How?

The answer: the event loop.

The Call Stack

The call stack is a LIFO (last-in, first-out) data structure that tracks which function is currently executing:

hljs javascript
function c() {
  console.log("c");
}

function b() {
  c();
  console.log("b");
}

function a() {
  b();
  console.log("a");
}

a();

Call stack states:

  1. a() pushed
  2. b() pushed
  3. c() pushed
  4. c() returns (popped), prints "c"
  5. b() returns (popped), prints "b"
  6. a() returns (popped), prints "a"

Stack overflow happens when functions recurse too deeply โ€” the stack runs out of space.

The Web APIs

When async operations are called (setTimeout, fetch, etc.), they're handed off to Web APIs (browser-provided environments outside the JS engine):

hljs javascript
console.log("1");

setTimeout(() => {
  console.log("3: timeout callback");
}, 0); // even 0ms doesn't run immediately!

console.log("2");

// Output: 1, 2, 3

The timeout callback doesn't run immediately because:

  1. console.log("1") runs
  2. setTimeout is handed to the Web API timer
  3. console.log("2") runs
  4. The main thread is free โ€” the timer callback enters the task queue
  5. The event loop picks it up and runs it

Task Queue (Macrotask Queue)

The task queue holds callbacks from:

  • setTimeout / setInterval
  • DOM events (click, scroll, keypress)
  • Network responses (XHR)
  • MessageChannel
hljs javascript
setTimeout(() => console.log("timeout 1"), 0);
setTimeout(() => console.log("timeout 2"), 0);

// Synchronous code runs first:
console.log("sync");

// Output: sync โ†’ timeout 1 โ†’ timeout 2

Microtask Queue

The microtask queue has higher priority than the task queue. It's processed completely after each task (before the next task runs):

Microtasks come from:

  • Promise callbacks (.then(), .catch(), .finally())
  • queueMicrotask()
  • MutationObserver
hljs javascript
console.log("1: sync");

setTimeout(() => console.log("4: timeout"), 0);

Promise.resolve()
  .then(() => console.log("3: promise"));

console.log("2: sync");

// Output: 1, 2, 3, 4
// Microtasks (promises) run before macrotasks (setTimeout)!

The Event Loop Algorithm

The event loop follows this cycle:

while (true) { 1. Pick one task from the task queue (if any) 2. Execute it (runs synchronous code + Web API calls) 3. Process ALL microtasks in the microtask queue (including any microtasks queued during this step) 4. If needed, render/update the UI 5. Go back to step 1 }

Key insight: The microtask queue is drained completely before moving to the next task.

Visualizing the Order

hljs javascript
console.log("--- Start ---");

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

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

queueMicrotask(() => console.log("Microtask"));

console.log("--- End ---");

// Output:
// --- Start ---
// --- End ---
// Promise 1      โ† microtask
// Microtask      โ† microtask
// Promise 2      โ† microtask (queued during Promise 1)
// setTimeout 1   โ† task
// setTimeout 2   โ† task

๐Ÿ’กWhy microtasks run before the next task

After the current task completes, the engine drains the entire microtask queue before picking the next task. This ensures Promises always resolve/reject before the next setTimeout callback runs.

Blocking the Event Loop

Since JS is single-threaded, blocking code prevents everything else from running:

hljs javascript
// โœ— Blocks the event loop โ€” UI freezes
function heavyWork() {
  let result = 0;
  for (let i = 0; i < 1_000_000_000; i++) {
    result += i;
  }
  return result;
}

// Nothing else can run while this loop executes

Solutions:

  1. Break work into chunks with setTimeout(fn, 0)
  2. Use Web Workers for truly parallel computation
  3. Offload to the server for expensive calculations
hljs javascript
// โœ“ Chunked work โ€” yields to the event loop between chunks
function processInChunks(items, chunkSize = 1000) {
  let index = 0;

  function processChunk() {
    const end = Math.min(index + chunkSize, items.length);
    for (; index < end; index++) {
      process(items[index]);
    }
    if (index < items.length) {
      setTimeout(processChunk, 0); // yield, run next chunk later
    }
  }

  processChunk();
}

requestAnimationFrame

For smooth animations, use requestAnimationFrame instead of setTimeout:

hljs javascript
function animate() {
  // This runs before each frame paint (~60fps)
  updatePositions();
  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

rAF callbacks run after microtasks and before the browser paints, ensuring smooth 60fps animation.

Async/Await and the Event Loop

await suspends the async function and schedules the rest as a microtask:

hljs javascript
async function run() {
  console.log("A");
  await Promise.resolve();
  console.log("C"); // scheduled as microtask
}

run();
console.log("B");

// Output: A, B, C

Summary Diagram

Call Stack Web APIs Task Queue Microtask Queue โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ [main script] โ†’ setTimeout() โ†’ [timer cb] โ†’ fetch() โ†’ [response cb] โ†’ Promise.then() โ†’ [promise cb] โ†’ addEventListener โ†’ [click cb] Event Loop: stack empty? โ†’ drain microtask queue โ†’ pick next task
โ–ถTry it yourself

Key Takeaways

  • JavaScript is single-threaded โ€” one thing runs at a time
  • The call stack tracks the current execution
  • Async operations go to Web APIs, then callbacks enter queues
  • Microtask queue (Promises, queueMicrotask) runs before task queue (setTimeout)
  • After every task, the entire microtask queue is drained before the next task
  • Never block the event loop โ€” use async, chunks, or Web Workers for heavy work

Ready to test your knowledge?

Take a quiz on what you just learned.

Take the Quiz โ†’