3 min read
On this page

The Event Loop

JavaScript is Single-Threaded

JavaScript runs on a single thread. It can do one thing at a time. If JavaScript blocked every time it waited for a network request, the browser would freeze. The event loop makes asynchronous code possible on a single thread.

The Call Stack

The call stack tracks what function is currently executing. Functions are pushed on call and popped on return.

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);
Stack: printSquare → square → multiply → (returns) → (returns) → console.log → (returns)

When the stack is busy, nothing else happens. The browser cannot render, handle clicks, or respond to input.

Web APIs & The Task Queue

The browser provides APIs that run outside the main thread: setTimeout, fetch, DOM events. When the work is done, the browser places a callback into the task queue. The event loop processes it only when the call stack is empty.

console.log("Start");
setTimeout(() => console.log("Timeout"), 1000);
console.log("End");
Start
End
Timeout

setTimeout(fn, 0) Does Not Run Immediately

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

Even with a delay of 0, the callback goes to the task queue. It runs only after all synchronous code finishes.

The Microtask Queue

Microtasks have higher priority than macrotasks. The event loop drains all microtasks before processing the next macrotask.

Microtask sources: Promise.then/.catch/.finally, queueMicrotask(), MutationObserver

Macrotask sources: setTimeout, setInterval, DOM events, requestAnimationFrame

console.log("1");
setTimeout(() => console.log("2 — macrotask"), 0);
Promise.resolve().then(() => console.log("3 — microtask"));
console.log("4");
1
4
3 — microtask
2 — macrotask

Microtasks Can Starve Macrotasks

The event loop processes all pending microtasks before moving on. A microtask that queues another microtask delays everything else.

Promise.resolve()
  .then(() => { console.log("Microtask 1"); return Promise.resolve(); })
  .then(() => { console.log("Microtask 2"); return Promise.resolve(); })
  .then(() => console.log("Microtask 3"));
setTimeout(() => console.log("Timeout"), 0);
// All three microtasks run before the timeout

The Event Loop Algorithm

  1. Execute all synchronous code on the call stack.
  2. When the stack is empty, drain the entire microtask queue.
  3. Optionally render (roughly every 16ms).
  4. Take one macrotask from the task queue and execute it.
  5. Go to step 2.

Real-World Consequences

Predicting Execution Order

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

Synchronous first, then microtasks, then macrotasks in order.

UI Freezing

Long synchronous code blocks everything. Break heavy work into chunks.

// Bad: blocks the main thread
function processAll(items) {
  items.forEach(item => heavyComputation(item));
}

// Better: process in chunks, yielding to the event loop
function processInChunks(items, chunkSize, callback) {
  let index = 0;
  function processChunk() {
    const end = Math.min(index + chunkSize, items.length);
    for (let i = index; i < end; i++) callback(items[i]);
    index = end;
    if (index < items.length) setTimeout(processChunk, 0);
  }
  processChunk();
}

Race Conditions

Even on a single thread, async code creates ordering issues.

// Bug: whichever fetch finishes last wins
let latestData = null;
async function fetchData(url) {
  const response = await fetch(url);
  latestData = await response.json();
}
fetchData("/api/page/1");
fetchData("/api/page/2");  // latestData could be either

// Fix: track which request is current
let currentRequestId = 0;
async function fetchData(url) {
  const requestId = ++currentRequestId;
  const response = await fetch(url);
  const data = await response.json();
  if (requestId === currentRequestId) latestData = data;
}

async/await Ordering

Code after await is a microtask, not synchronous.

async function example() {
  console.log("A");
  await Promise.resolve();
  console.log("B");  // microtask
}
example();
console.log("C");
A
C
B

Common Pitfalls

Assuming setTimeout(fn, 0) runs immediately. It runs after all synchronous code and all microtasks. The minimum delay is often 4ms in browsers.

Starving the task queue with microtasks. A microtask that queues another microtask in an infinite loop freezes the browser permanently.

// This freezes the browser
function bad() { Promise.resolve().then(bad); }

Blocking the main thread with synchronous computation. JSON parsing, large array operations, or complex calculations block rendering. Move heavy work to a Web Worker or break it into chunks.

Assuming network requests return in order. Two fetch calls may resolve in any order. Track request IDs or use AbortController.

Key Takeaways

  • JavaScript is single-threaded. The event loop makes asynchronous code possible.
  • The call stack executes synchronous code. When empty, the event loop checks the queues.
  • Microtasks (Promises, queueMicrotask) always run before macrotasks (setTimeout, events).
  • setTimeout(fn, 0) means "run after everything currently queued," not "run now."
  • Long synchronous operations freeze the UI. Break heavy work into chunks or use Web Workers.
  • Async code on a single thread still creates race conditions. Track request IDs or use AbortController.