Articles › JavaScript

The JavaScript Event Loop Explained: Call Stack, Tasks and Microtasks

Understand how JavaScript runs async code on a single thread: the call stack, Web APIs, the task and microtask queues, rendering, and why setTimeout(fn, 0) isn't immediate.

JavaScript is single-threaded: it can only execute one piece of code at a time. Yet a web page can fetch data, run animations, respond to clicks and wait on timers all at once. The mechanism that makes this possible is the event loop. Understanding it explains execution-order puzzles, frozen UIs and many subtle async bugs.

The pieces

  1. Call stack — where JavaScript executes functions. Each call pushes a frame; each return pops it.
  2. Web APIs / runtime APIs — timers, network (fetch), DOM events, file I/O in Node.js. They run outside the JavaScript thread.
  3. Task queue (macrotasks) — callbacks ready to run: timer callbacks, UI events, message events.
  4. Microtask queue — higher-priority callbacks: Promise reactions (then, await continuations), queueMicrotask, MutationObserver.
  5. Event loop — the scheduler that moves work from the queues onto the call stack.

The loop, step by step

In a browser, one iteration of the event loop roughly does this:

  1. Take one task from the task queue and run it to completion.
  2. Run all microtasks until the microtask queue is empty (including microtasks added meanwhile).
  3. If it's time to update the screen: run requestAnimationFrame callbacks, recalculate styles and layout, paint.
  4. Repeat.

The key rule: nothing interrupts running JavaScript. A task always finishes before anything else — another task, a microtask or a repaint — can happen.

A classic ordering puzzle

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

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

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

console.log("2: script end");

Output:

1: script start
2: script end
3: promise 1
4: promise 2
5: timeout

Why:

  • The whole script is the current task; it logs 1 and 2.
  • setTimeout hands its callback to the timer API, which queues a task after ≥0 ms.
  • then callbacks are microtasks, which run right after the current task — before the next task.
  • Only then does the event loop pick the timeout task.

async/await in the event loop

await is syntax over Promises: everything after an await is a microtask continuation.

async function load() {
  console.log("B");
  await null;              // pause; rest becomes a microtask
  console.log("D");
}

console.log("A");
load();
console.log("C");
// A, B, C, D

The function runs synchronously until the first await, then yields control back to the caller.

Why setTimeout(fn, 0) isn't immediate

0 means "as soon as possible after the current task and all microtasks" — and browsers clamp nested timers to at least ~4 ms. Timers are also delayed when the main thread is busy:

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

while (Date.now() - start < 1000) {} // busy loop blocks the thread
// "fired after 1000 ms"

Blocking the main thread freezes the UI

Because rendering happens between tasks, a long-running task means no repaints and no input handling:

button.addEventListener("click", () => {
  spinner.hidden = false;      // the browser can't paint this yet…
  heavyCalculation();          // …because this blocks the thread for 2 s
  spinner.hidden = true;       // by the time it paints, the spinner is hidden again
});

Solutions:

  • Split work into chunks and yield between them:
async function processInChunks(items, handle, chunkSize = 200) {
  for (let i = 0; i < items.length; i += chunkSize) {
    items.slice(i, i + chunkSize).forEach(handle);
    await new Promise((r) => setTimeout(r, 0)); // let the browser paint and handle input
  }
}
  • Use scheduler.yield() where supported, which is designed for exactly this.
  • Move heavy computation to a Web Worker, which has its own thread and event loop.

Microtask starvation

Because the event loop drains all microtasks before rendering, an endless chain of microtasks freezes the page just like an infinite loop:

function loop() {
  Promise.resolve().then(loop); // ❌ the page never repaints
}

Timers (macrotasks) don't have this problem, because rendering can happen between tasks.

requestAnimationFrame: work synced with painting

For animations, requestAnimationFrame runs your callback right before the browser paints — once per frame (~60 or 120 times per second):

function animate(time) {
  box.style.transform = `translateX(${Math.sin(time / 500) * 100}px)`;
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

Animation libraries like GSAP and smooth-scroll libraries like Lenis use exactly this loop. Avoid setInterval for animations: it isn't synchronized with screen refreshes and keeps running in background tabs.

Node.js has phases too

Node.js uses the libuv event loop with phases (timers, pending callbacks, poll, check, close). Practical differences:

  • process.nextTick runs before promise microtasks.
  • setImmediate runs in the check phase, after I/O callbacks.
  • Synchronous CPU work blocks every request on the server — a much bigger problem than in a browser tab.
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
// nextTick, promise, then timeout/immediate (order between those two can vary at top level)

Summary table

Mechanism Queue Runs
Synchronous code call stack immediately
Promise.then, await continuation, queueMicrotask microtask after the current task, before rendering
setTimeout, setInterval, DOM events, fetch callbacks task one per loop iteration
requestAnimationFrame rendering step before the next paint
Web Worker separate thread in parallel

Summary

The event loop runs one task at a time to completion, then drains all microtasks, then lets the browser render. Promises and await continuations are microtasks, timers and events are tasks, and requestAnimationFrame is tied to painting. Keep tasks short, split heavy work or move it to Web Workers, and you'll write JavaScript that stays responsive under load.

FAQ

Is JavaScript really single-threaded?

Your JavaScript code runs on one main thread, but the browser or Node.js uses other threads for networking, timers, I/O and Web Workers.

Do promises make code run in parallel?

No. Promises schedule continuations; the actual parallelism comes from the runtime's I/O. CPU-heavy JavaScript still blocks the thread.

Why does my state update appear later in React?

React batches updates and schedules rendering; the update is applied in a later step of the same or a subsequent task, not synchronously when you call the setter.