Toolmingo
Guides8 min read

JavaScript Event Loop Explained: A Visual Guide

Understand the JavaScript event loop, call stack, task queue, microtask queue, and how async code actually runs. Includes visual diagrams, setTimeout vs Promises order, and common pitfalls.

JavaScript is single-threaded — it can only run one piece of code at a time. Yet it handles network requests, timers, and user events without blocking. The event loop is the mechanism that makes this possible. Once you understand it, async code ordering stops being mysterious.


Quick-reference table

Concept What it is Priority
Call stack Tracks currently executing functions (LIFO) Runs first
Web APIs Browser/Node environment (setTimeout, fetch, DOM) Offloads async work
Microtask queue Promise callbacks, queueMicrotask, MutationObserver Drains after each task
Task queue (macrotask) setTimeout, setInterval, I/O callbacks, UI events One task per loop tick
Event loop Moves tasks from queues onto the empty call stack Orchestrates all of the above

The four components

1. Call stack

The call stack tracks function calls. When you call a function, it's pushed onto the stack. When it returns, it's popped off. JavaScript can only execute what's on top of the stack.

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

function main() {
  const msg = greet("Alice"); // greet pushed, then popped
  console.log(msg);           // console.log pushed, then popped
}

main(); // main pushed onto stack

Stack state (bottom → top):

main()
  greet("Alice")   ← executes, returns, popped
main()
  console.log(msg) ← executes, returns, popped
main()             ← returns, popped
(empty)

2. Web APIs / Node APIs

When you call setTimeout, fetch, addEventListener, or any async API, the browser (or Node) handles the wait outside the call stack. Your JavaScript thread is free to do other work.

console.log("A");

setTimeout(() => {
  console.log("B"); // moved to Web APIs, timer starts
}, 1000);

console.log("C");

// Output: A, C, B

setTimeout is not JavaScript — it's a browser API. The callback is held by the browser until the timer fires, then it's placed in the task queue.

3. Task queue (macrotask queue)

When a Web API is done (timer expired, fetch completed, user clicked), it places the callback in the task queue. The event loop picks tasks from this queue one at a time — but only when the call stack is empty.

Macrotask sources:

  • setTimeout / setInterval
  • I/O callbacks (Node.js file reads, network)
  • UI events (click, keypress)
  • MessageChannel
  • setImmediate (Node.js only)

4. Microtask queue

Microtasks have higher priority than macrotasks. After every task completes, the event loop drains the entire microtask queue before moving to the next task.

Microtask sources:

  • Promise.then() / .catch() / .finally() callbacks
  • async/await continuations (they're sugar over Promises)
  • queueMicrotask(fn)
  • MutationObserver callbacks

How the event loop works

┌─────────────────────────────────────────────────┐
│                  JavaScript Engine               │
│                                                  │
│   ┌──────────────┐    ┌───────────────────────┐ │
│   │  Call Stack  │    │       Web APIs        │ │
│   │              │    │  setTimeout / fetch   │ │
│   │  fn3()       │    │  DOM / Node I/O       │ │
│   │  fn2()       │    └────────────┬──────────┘ │
│   │  fn1()       │                 │             │
│   └──────┬───────┘                 │             │
│          │ (when empty)            │             │
│          ▼                         ▼             │
│   ┌──────────────────────────────────────────┐  │
│   │  Microtask Queue  (Promise callbacks)    │  │
│   │  [task1, task2, task3, ...]              │  │
│   └──────────────────┬───────────────────────┘  │
│                      │ (drain ALL first)         │
│                      ▼                           │
│   ┌──────────────────────────────────────────┐  │
│   │  Task Queue (setTimeout, events, I/O)   │  │
│   │  [task4, task5, ...]                     │  │
│   └──────────────────────────────────────────┘  │
└─────────────────────────────────────────────────┘

One loop tick:

  1. Pick one macrotask from the task queue (or run script)
  2. Execute it (call stack empties)
  3. Drain all microtasks (run every pending microtask, including any new ones added during this step)
  4. Render update (browser only)
  5. Go back to step 1

Execution order examples

setTimeout vs Promise

console.log("1");

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

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

console.log("4");

// Output: 1, 4, 3, 2

Why? The main script runs synchronously (1, 4). After the script (a macrotask), all microtasks drain (3). Then the next macrotask runs (2).

Nested microtasks

Microtasks added during microtask processing are run before the next macrotask:

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

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

// Output:
// microtask 1
// microtask 2 (nested)
// microtask 3
// macrotask

async/await order

await pauses the async function and resumes it as a microtask:

async function fetchData() {
  console.log("A");
  await Promise.resolve(); // pauses here
  console.log("B");        // runs as microtask
}

console.log("start");
fetchData();
console.log("end");

// Output: start, A, end, B

fetchData() runs synchronously until the first await, then the rest is scheduled as a microtask. The outer code finishes ("end") before the continuation runs ("B").

Multiple awaits

async function run() {
  console.log("1");
  await delay(0); // each await = one microtask checkpoint
  console.log("2");
  await delay(0);
  console.log("3");
}

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

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

// Output: 1, 4, 2, 3

Each await delay(0) yields to a setTimeout, so "2" and "3" come after the synchronous "4", delayed by separate event loop ticks.


Real-world patterns

Don't block the event loop

Long synchronous operations block the entire UI and all async callbacks:

// BAD — blocks for ~1 second on large arrays
function slowSort(arr) {
  return arr.sort((a, b) => a - b); // synchronous, can't interrupt
}

// BETTER — yield to the event loop between chunks
async function chunkedSort(arr, chunkSize = 1000) {
  const sorted = [...arr];
  for (let i = 0; i < sorted.length; i += chunkSize) {
    sorted.splice(i, chunkSize, ...sorted.slice(i, i + chunkSize).sort());
    await new Promise(resolve => setTimeout(resolve, 0)); // yield
  }
  return sorted;
}

Scheduling priorities

// Highest priority — runs before next render
queueMicrotask(() => console.log("microtask"));

// Low priority — one macrotask slot
setTimeout(() => console.log("timeout"), 0);

// requestAnimationFrame — before next paint (browser)
requestAnimationFrame(() => console.log("rAF"));

// Order: microtask → rAF → timeout (implementation varies)

Avoid microtask starvation

If microtasks keep adding more microtasks, macrotasks (and rendering) never run:

// BAD — infinite microtask loop starves everything else
function loop() {
  Promise.resolve().then(loop);
}
loop(); // locks up the browser

// GOOD — use setTimeout to yield macrotask slot
function loop() {
  setTimeout(loop, 0);
}

Node.js specifics

Node.js has its own event loop phases (powered by libuv):

Phase Runs
timers setTimeout / setInterval callbacks
pending callbacks I/O errors from previous iteration
idle, prepare Internal use
poll Retrieve new I/O events; execute callbacks
check setImmediate callbacks
close callbacks e.g., socket.on("close", ...)

Between each phase, Node drains microtasks (Promises) and process.nextTick callbacks. process.nextTick has higher priority than Promises:

Promise.resolve().then(() => console.log("Promise"));
process.nextTick(() => console.log("nextTick"));
setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate"));

// Output:
// nextTick
// Promise
// setTimeout (usually before setImmediate, but not guaranteed)
// setImmediate

6 common mistakes

Mistake Problem Fix
setTimeout(fn, 0) assumed instant Still goes through macrotask queue — microtasks run first Use queueMicrotask for truly immediate async
Blocking loop inside async function Synchronous code still blocks even inside async Break work into chunks with await
Assuming await pauses the whole program Only pauses the current async function Other microtasks still interleave
Infinite Promise chain as "loop" Starves rendering and macrotasks Use setTimeout for recurring work
process.nextTick abuse in Node Starves I/O if overused Reserve for rare correction cases
Unhandled rejection lost silently Promise error with no .catch — no throw, no log Always .catch() or try/catch around await

Quick reference

// Check execution order mentally:
// 1. Synchronous code runs first (call stack)
// 2. Microtasks drain (Promises, queueMicrotask)
// 3. One macrotask runs (setTimeout, events, I/O)
// 4. Microtasks drain again
// 5. Repeat

// Useful tools for yielding
await Promise.resolve();           // yield to microtask queue
await new Promise(r => setTimeout(r, 0)); // yield to macrotask queue
queueMicrotask(fn);                // schedule microtask explicitly
requestAnimationFrame(fn);         // schedule before next paint (browser)
setImmediate(fn);                  // schedule after poll phase (Node.js)
process.nextTick(fn);              // schedule before Promises (Node.js)

FAQ

Why is JavaScript single-threaded? When JavaScript was created for browsers in 1995, multi-threaded DOM access would have created race conditions that were very hard to manage. Single-threading simplifies the programming model. Web Workers add parallelism for CPU-heavy tasks without shared DOM access.

What's the difference between the task queue and microtask queue? Microtasks (Promises) drain completely after each task before any new macrotask starts. This means if you queue 100 microtasks, all 100 run before the next setTimeout callback. The macrotask queue processes one task per event loop tick.

Does async/await use threads? No. async/await is syntax sugar over Promises and the event loop. An await suspends the current function, returns control to the call stack, and resumes when the awaited Promise settles — all in one thread.

Why does setTimeout(fn, 0) not mean "run immediately"? setTimeout is a Web API that always moves the callback to the macrotask queue, even with a 0ms delay. All pending microtasks run first, plus the browser may impose a minimum delay (~4ms in nested timeouts per the HTML spec).

What is requestAnimationFrame and where does it fit? requestAnimationFrame is a browser API that schedules a callback before the browser's next repaint. It runs after microtasks but before most macrotasks, making it ideal for animation code that needs to sync with the display refresh rate (typically 60fps).

How can I visualize the event loop in action? Use the Loupe tool (browser-based) or the Performance tab in Chrome DevTools. In Node.js, --inspect mode with Chrome DevTools shows async task timing.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools