DocsHub
Advanced

Event Loop

Learn how JavaScript handles async operations through the event loop, call stack, and task queues.

Event Loop

You already know JavaScript is single-threaded — it can only do one thing at a time. And you know it handles async operations without freezing. But how exactly does that work?

The answer is the event loop — the system that coordinates everything JavaScript does.

Understanding the event loop explains why code sometimes runs in a different order than you expect, why some async operations run before others, and how JavaScript stays responsive while handling multiple operations.


The Four Parts

The event loop system has four key components:

checked first Call Stackruns code Web APIstimers, fetch, DOM events Task Queuemacrotasks Microtask QueuePromises, queueMicrotask
  • Call Stack — where code actually runs, one frame at a time
  • Web APIs — browser handles async operations here — timers, network requests, events
  • Microtask Queue — high priority callbacks — Promise .then(), queueMicrotask()
  • Task Queue (Macrotask Queue) — lower priority callbacks — setTimeout, setInterval, DOM events

The Call Stack

The call stack is a data structure that tracks which function is currently running. When a function is called, it is pushed onto the stack. When it returns, it is popped off.

function greet(name) {
  return `Hello, ${name}`;
}

function main() {
  const message = greet("Ali");
  console.log(message);
}

main();

The stack looks like this as it executes:

main() called     → [main]
greet() called    → [main, greet]
greet() returns   → [main]
console.log()     → [main, console.log]
console.log done  → [main]
main() returns    → []

JavaScript can only execute one frame at a time. The stack must be empty before the event loop can push the next task onto it.


How Async Operations Work

When you call an async operation — setTimeout, fetch, an event listener — JavaScript hands it to the Web APIs. The Web API handles it in the background while JavaScript keeps running.

console.log("1 — start");

setTimeout(() => {
  console.log("3 — timeout");
}, 0);

console.log("2 — end");

// 1 — start
// 2 — end
// 3 — timeout

Even with a 0ms delay, "3 — timeout" prints last. Here is exactly why:

1. console.log("1") → runs on stack → prints "1 — start"
2. setTimeout() → handed to Web API → Web API starts 0ms timer
3. console.log("2") → runs on stack → prints "2 — end"
4. Stack is now empty
5. Timer finished → callback moved to Task Queue
6. Event loop sees empty stack → moves callback to stack
7. Callback runs → prints "3 — timeout"

The callback cannot run until the stack is empty — even with a 0ms delay.


Microtasks vs Macrotasks

This is the most important distinction in the event loop. There are two queues — and they have different priorities.

Macrotasks (Task Queue):

  • setTimeout
  • setInterval
  • DOM events
  • setImmediate (Node.js)

Microtasks (Microtask Queue):

  • Promise .then(), .catch(), .finally()
  • queueMicrotask()
  • MutationObserver
  • await continuations

The rule — after every macrotask, JavaScript drains the entire microtask queue before picking up the next macrotask.

console.log("1 — start");

setTimeout(() => console.log("4 — setTimeout"), 0); // macrotask

Promise.resolve()
  .then(() => console.log("3 — Promise then")); // microtask

console.log("2 — end");

// 1 — start
// 2 — end
// 3 — Promise then
// 4 — setTimeout

Both setTimeout and the Promise are async — but the Promise resolves first because microtasks have higher priority than macrotasks.


The Event Loop in Detail

Here is the exact algorithm the event loop follows:

no yes no yes no yes Start Run current scriptfill call stack Stack empty? Microtask queue empty? Run ALL microtasksone by one Task queue has items? Wait for new tasks Take ONE macrotaskrun it

Key insight — microtasks are completely drained before any macrotask runs. If a microtask adds another microtask, that also runs before any macrotask.


A Detailed Example

console.log("script start");      // 1

setTimeout(() => {
  console.log("setTimeout");      // 6
}, 0);

Promise.resolve()
  .then(() => {
    console.log("promise 1");     // 4
  })
  .then(() => {
    console.log("promise 2");     // 5
  });

console.log("script end");        // 2 — wait, where is 3?

Wait — what is 3? Let's trace carefully:

Synchronous code runs first:
  1. "script start"
  2. setTimeout registered → Web API
  3. Promise.resolve() created → .then callbacks scheduled as microtasks
  4. "script end"

Stack is now empty. Check microtask queue:
  5. Run "promise 1" → .then returns → schedules "promise 2" as microtask
  6. Run "promise 2"

Microtask queue empty. Check task queue:
  7. Run setTimeout callback → "setTimeout"

Output:

script start
script end
promise 1
promise 2
setTimeout

Notice — "promise 2" runs before "setTimeout" even though "promise 2" was not scheduled until "promise 1" ran. All microtasks drain completely before any macrotask runs.


async/await and the Event Loop

await pauses the async function and yields control back to the caller — everything after the await becomes a microtask.

async function fetchData() {
  console.log("2 — inside async function");
  await Promise.resolve();
  console.log("4 — after await"); // scheduled as microtask
}

console.log("1 — before async call");
fetchData();
console.log("3 — after async call");

// 1 — before async call
// 2 — inside async function
// 3 — after async call
// 4 — after await

fetchData() runs synchronously until it hits await. Then it pauses — returns control to the caller — and the rest of fetchData is scheduled as a microtask. The synchronous "3 — after async call" runs before the microtask continues.


Blocking the Event Loop

If synchronous code takes too long, it blocks the entire event loop — no callbacks fire, no UI updates, nothing responds.

// ❌ Blocks the event loop
function blockingOperation() {
  const start = Date.now();
  while (Date.now() - start < 3000) {
    // spin for 3 seconds — nothing else can run
  }
  console.log("Done blocking");
}

setTimeout(() => console.log("This should fire after 1s"), 1000);
blockingOperation(); // ← blocks for 3 seconds

// "Done blocking" prints at 3s
// "This should fire after 1s" ALSO prints at 3s — it was stuck waiting

The timer callback was ready at 1 second — but the call stack was not empty until 3 seconds. The event loop could not process it.

How to avoid blocking

// ✅ Break heavy work into chunks with setTimeout
function processChunk(data, index = 0) {
  const chunkSize = 1000;
  const end = Math.min(index + chunkSize, data.length);

  for (let i = index; i < end; i++) {
    heavyProcess(data[i]);
  }

  if (end < data.length) {
    setTimeout(() => processChunk(data, end), 0);
    // yields to event loop between chunks
  }
}

// ✅ Use Web Workers for truly CPU-intensive work
const worker = new Worker("heavy-task.js");
worker.postMessage(largeData);
worker.onmessage = (e) => console.log("Result:", e.data);

setTimeout(() => ..., 0) yields to the event loop between chunks — letting other callbacks run.


queueMicrotask() — Schedule a Microtask Directly

console.log("1");

queueMicrotask(() => {
  console.log("3 — microtask");
});

console.log("2");

// 1
// 2
// 3 — microtask

queueMicrotask() directly schedules a function in the microtask queue — without needing a Promise. Useful when you need guaranteed microtask timing without creating a Promise.


setTimeout vs setInterval vs requestAnimationFrame

// setTimeout — run once after delay
setTimeout(() => console.log("once"), 1000);

// setInterval — run repeatedly
const id = setInterval(() => console.log("repeat"), 1000);
clearInterval(id); // stop it

// requestAnimationFrame — run before next repaint — 60fps
function animate() {
  updateAnimation();
  requestAnimationFrame(animate); // schedule next frame
}
requestAnimationFrame(animate);

requestAnimationFrame is the right tool for animations — it runs in sync with the browser's repaint cycle (usually 60 times per second) giving you smooth animations. setTimeout has timing imprecision and does not sync with repaints.


A Real Example — Understanding Execution Order

async function loadDashboard() {
  console.log("1 — loadDashboard starts");

  const userPromise = fetch("/api/user");       // Web API — starts immediately
  const postsPromise = fetch("/api/posts");     // Web API — starts immediately

  console.log("2 — both fetches started");

  const userResponse = await userPromise;       // pause — wait for user fetch
  console.log("3 — user response received");

  const user = await userResponse.json();       // pause — parse JSON
  console.log("4 — user data parsed");

  const postsResponse = await postsPromise;     // probably already done
  console.log("5 — posts response received");

  const posts = await postsResponse.json();
  console.log("6 — posts data parsed");

  return { user, posts };
}

console.log("A — before loadDashboard");
loadDashboard();
console.log("B — after loadDashboard call");

// A — before loadDashboard
// 1 — loadDashboard starts
// 2 — both fetches started
// B — after loadDashboard call
// (when fetches complete — back as microtasks)
// 3 — user response received
// 4 — user data parsed
// 5 — posts response received
// 6 — posts data parsed

"B — after loadDashboard call" prints before the fetch results because loadDashboard pauses at the first await and returns control to the caller. Both fetches are already running in the Web API layer.


Common Event Loop Interview Questions

Why does this print in this order?

setTimeout(() => console.log("A"), 0);
Promise.resolve().then(() => console.log("B"));
console.log("C");

// C — synchronous
// B — microtask (higher priority than macrotask)
// A — macrotask

Can microtasks block the event loop?

// ❌ Yes — infinite microtasks starve macrotasks
function infiniteMicrotasks() {
  Promise.resolve().then(infiniteMicrotasks);
}

setTimeout(() => console.log("Never runs"), 0);
infiniteMicrotasks(); // macrotask never gets a chance

The microtask queue never empties — the macrotask never runs. This is why you should not create recursive microtasks.


Summary

  • JavaScript is single-threaded — the call stack runs one thing at a time
  • Async operations are handed to Web APIs — the browser handles them in the background
  • When done, callbacks are placed in either the microtask queue or task queue
  • Microtasks — Promise callbacks, queueMicrotask() — run before any macrotask
  • MacrotaskssetTimeout, setInterval, DOM events — one per event loop cycle
  • After every macrotask, all microtasks are drained before the next macrotask
  • await pauses an async function and schedules the rest as a microtask
  • Never block the call stack — break heavy work into chunks or use Web Workers
  • setTimeout(fn, 0) does not run immediately — it runs after the current stack and all microtasks are done
  • requestAnimationFrame is the right tool for animations — syncs with the browser's repaint cycle

On this page