Skip to content
elephantoo

The event loop

Lesson 25 of 34 15 min read

The call stack, task and microtask queues, and why setTimeout(fn, 0) is not instant.


JavaScript runs your code on a single thread — it can only do one thing at a time. Yet web pages fetch data, run timers and respond to clicks all at once, and Node servers handle thousands of connections. The trick is the event loop. Understanding it explains why setTimeout(fn, 0) isn't instant, why promises run "first", and why a slow loop freezes the page.

The call stack#

The call stack tracks which function is running. Calling a function pushes it on; returning pops it off:

JavaScript
function multiply(a, b) {
  return a * b;
}
function square(n) {
  return multiply(n, n);
}
function printSquare(n) {
  console.log(square(n));
}
printSquare(4); // 16

While running, the stack grows: printSquare → square → multiply, then unwinds as each returns. When you see a stack trace in an error, you're looking at this stack. Infinite recursion fills it up: RangeError: Maximum call stack size exceeded.

Synchronous code runs to completion on the stack. Nothing else can happen until it finishes.

Where asynchronous work happens#

Timers, network requests, file reads and user events aren't done by the JavaScript engine itself. They're provided by the host environment — the browser (Web APIs) or Node.js (libuv). You hand them a callback, they do the waiting elsewhere, and when the result is ready they queue your callback.

JavaScript
console.log("1: start");

setTimeout(() => {
  console.log("3: timer finished");
}, 0);

console.log("2: end");
Output
1: start
2: end
3: timer finished

Even with a 0 ms delay, the timer callback can't run until the current code has finished and the stack is empty.

The event loop#

The event loop is a simple, endless cycle:

  1. Run the current task (e.g. your script, a timer callback, an event handler) until the call stack is empty.
  2. Run all queued microtasks (promise callbacks, queueMicrotask), including any microtasks they add.
  3. (Browser) Render the page if needed — update layout and paint.
  4. Take the next task from the task queue and go back to step 1.

There are two main queues:

QueueWhat goes in itWhen it runs
Task queue (macrotasks)setTimeout/setInterval callbacks, I/O, UI events, MessageChannelOne task per loop turn
Microtask queuePromise .then/catch/finally callbacks, code after await, queueMicrotaskAll of them, right after each task

Microtasks run before the next task#

JavaScript
console.log("script start");

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

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

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

console.log("script end");
Output
script start
script end
promise 1
microtask
promise 2
timeout

Step by step:

  1. The script is the current task: it logs script start and script end, schedules a timer (task) and two microtasks.
  2. The stack is empty, so all microtasks run: promise 1, then microtask. Running promise 1 queued promise 2 as a new microtask, which also runs now.
  3. Only then does the event loop pick the next task: the timer, logging timeout.

async/await follows the same rules

Code after an await is a microtask continuation:

JavaScript
async function load() {
  console.log("B: inside load, before await");
  await null;
  console.log("D: after await");
}

console.log("A: start");
load();
console.log("C: after calling load");
Output
A: start
B: inside load, before await
C: after calling load
D: after await

An async function runs synchronously until its first await, then returns to the caller. The rest resumes as a microtask.

Blocking the main thread#

Because everything shares one thread, a long synchronous task blocks timers, rendering and clicks:

JavaScript
const start = Date.now();
setTimeout(() => console.log(`timer fired after ${Date.now() - start} ms`), 10);

// Busy-wait for ~500 ms
while (Date.now() - start < 500) {
  // doing "work"
}
console.log("loop done");
Output
loop done
timer fired after 500 ms

The timer was due after 10 ms but had to wait for the loop. In a browser, the page would also be frozen for that half-second — no scrolling, no clicks, no animations. That's what "jank" is.

Keeping the page responsive

  • Break up big jobs into chunks and yield between them so the browser can render and handle input:
JavaScript
async function processInChunks(items, handle, chunkSize = 500) {
  for (let i = 0; i < items.length; i += chunkSize) {
    items.slice(i, i + chunkSize).forEach(handle);
    await new Promise((resolve) => setTimeout(resolve, 0)); // yield to the event loop
  }
}

let total = 0;
processInChunks(Array.from({ length: 2000 }, (_, i) => i), (n) => (total += n)).then(() =>
  console.log("total:", total)
); // total: 1999000

Modern Chromium browsers also offer scheduler.yield() for exactly this.

  • Move heavy computation to a Web Worker (browser) or a worker thread (Node). Workers run on separate threads and communicate with messages.
  • Don't flood the microtask queue — an endless chain of microtasks starves rendering just like a busy loop, because the browser only renders after the microtask queue is empty.

Timers in detail#

JavaScript
const id = setTimeout(() => console.log("never runs"), 1000);
clearTimeout(id); // cancel it

let ticks = 0;
const interval = setInterval(() => {
  ticks++;
  console.log(`tick ${ticks}`);
  if (ticks === 3) clearInterval(interval);
}, 100);
Output
tick 1
tick 2
tick 3
  • The delay is a minimum, not a guarantee.
  • Browsers clamp nested timers to at least ~4 ms and throttle timers in background tabs to save battery.
  • For animations, use requestAnimationFrame(callback), which runs just before the browser paints the next frame.

Node.js specifics#

Node's event loop has a few extra phases (timers, I/O polling, setImmediate "check" phase…) and two extra microtask-like queues:

JavaScript
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("sync");
Output
sync
nextTick
promise
timeout
immediate

process.nextTick callbacks run even before promise microtasks. The relative order of timeout and immediate from the main script can vary between runs; inside an I/O callback, setImmediate always runs first. Day to day, you rarely need these details — use promises and async/await.

Why this design?#

A single thread with an event loop means you never have two pieces of JavaScript modifying the same variable at the same instant — no locks, no data races. The cost: you must never block the thread for long, and you need patterns to deal with results that arrive later. The next three lessons cover those patterns — callbacks, promises and async/await.

Common mistakes#

  • Expecting setTimeout(fn, 0) to run before promise callbacks.
  • Reading a value right after starting an async operation and getting undefined, because the result hasn't arrived yet.
  • Running a heavy loop on the main thread and wondering why the UI freezes.
  • Using setInterval for work that can take longer than the interval — calls pile up. Prefer a setTimeout that re-schedules itself after each run.

What's next#

Next: callbacks — the original way of handling asynchronous results, and why we moved on to promises.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What is the output order of: console.log("A"); setTimeout(() => console.log("B"), 0); Promise.resolve().then(() => console.log("C")); console.log("D");

  2. 2.Why does a long-running loop make a web page freeze?

  3. 3.What does setTimeout(fn, 1000) guarantee?

Finished reading?

Mark this lesson complete to track your progress.