JavaScript Event Loop Explained: Stack, Queues, Microtasks

Written By  Crosscheck Team

Content Team

May 22, 2026 13 minutes

JavaScript Event Loop Explained: Stack, Queues, Microtasks

How the JavaScript Event Loop Actually Works in 2026

The JavaScript event loop is the runtime mechanism that lets single-threaded JavaScript handle asynchronous work without blocking. It moves callbacks between four actors — the call stack, the web APIs (or Node APIs), the macrotask queue, and the microtask queue — in a strict, repeatable order. Once you can predict that order, async bugs stop being magic. The rules below are the same ones the V8, JavaScriptCore, and libuv teams ship, applied to two walked-through code examples that print exactly the lines this article says they print.

Key takeaways

  • JavaScript runs one thing at a time on the call stack — async APIs hand work to the browser or Node, then push a callback into a queue when they finish.
  • Microtasks (Promise.then, queueMicrotask, MutationObserver) drain completely between every macrotask, including between individual timer callbacks.
  • Macrotasks (setTimeout, setInterval, I/O, setImmediate, MessageChannel) run one per loop turn.
  • async/await is syntactic sugar over Promises — each await schedules the rest of the function as a microtask.
  • Node.js runs the same microtask rules as the browser, but its macrotask side is split into six phases: timers, pending, idle/prepare, poll, check, close.

The four actors that drive every async program

A useful mental model is to draw the runtime as four boxes connected by one rule.

  • Call stack. The list of function calls currently executing. JavaScript runs whatever sits on top. When a function returns, its frame pops off. When the stack is empty, the loop is allowed to schedule something new.
  • Web APIs / Node APIs. Everything that lives outside the JavaScript engine but is callable from it — setTimeout, fetch, XMLHttpRequest, addEventListener, the DOM, Node's fs, net, crypto. Your code asks the API to do something; the API does it in the background.
  • Macrotask queue (sometimes called the task queue or callback queue). When a web/Node API finishes its work, the callback you handed it gets pushed into this queue.
  • Microtask queue. A separate, higher-priority queue for callbacks that resolve a Promise, anything passed to queueMicrotask, and MutationObserver notifications. Node's process.nextTick queue is microtask-adjacent and runs even before the standard microtask queue.

The event loop is a single rule applied forever: if the call stack is empty, drain the microtask queue completely, then take one task from the macrotask queue and run it to completion — then repeat. Rendering and paint slot in between iterations on the browser side. Nothing else interrupts.

The HTML spec calls macrotasks just "tasks." The term "macrotask" was popularised by Jake Archibald's tasks, microtasks, queues and schedules post and stuck because the symmetry with "microtask" is easier to teach.


Sync vs async — what runs immediately

Synchronous code runs on the stack the moment it's reached. Async code — anything routed through a web or Node API — is registered immediately but its callback only fires once (a) the API finishes and (b) the stack is empty.

console.log('A'); // sync, runs now
setTimeout(() => console.log('B'), 0); // async, queued
console.log('C'); // sync, runs now
// Output: A C B

The line setTimeout(..., 0) is the classic example of why "0" isn't 0. The browser must wait for the call stack to clear and for any pending microtasks to drain before the timer callback gets its turn. In practice, browsers also clamp nested setTimeout(..., 0) calls to a minimum of about 4 milliseconds after a few layers of nesting — a long-standing HTML-spec rule that surprises developers tuning animation timing. If you need true "as soon as possible" deferral, queueMicrotask or Promise.resolve().then is closer to the literal meaning.


Microtasks vs macrotasks — which goes where

The split decides everything. A single rule applied to both:

QueueTriggered byWhen it runsDrains how much
MicrotaskPromise.then / catch / finally, queueMicrotask, MutationObserver, await resumption, process.nextTick (Node)After every task and after the stack emptiesCompletely — including microtasks scheduled by other microtasks
MacrotasksetTimeout, setInterval, setImmediate (Node), I/O completion, UI events, MessageChannel.postMessage, script execution itselfOne per loop turnOne task only, then microtasks drain again

The "drain completely" part matters. If a microtask schedules another microtask, the loop will keep going until the microtask queue is empty — which is why an infinite chain of Promise.resolve().then(...) will starve the rest of the program. Microtasks were exposed to web developers via queueMicrotask in 2018, and the API now ships in every evergreen browser plus Node.js 12 and later — see the MDN microtask guide for the standardised semantics.


Example 1 — the canonical execution-order puzzle

The example below is the one every senior interview reaches for. The comments numbered 1 through 7 show the exact order each line prints.

console.log('1: script start'); // 1

setTimeout(() => {
  console.log('5: setTimeout'); // 5
}, 0);

Promise.resolve()
  .then(() => {
    console.log('3: promise 1'); // 3
  })
  .then(() => {
    console.log('4: promise 2'); // 4
  });

queueMicrotask(() => {
  console.log('6: queueMicrotask'); // 6 — wait, why not earlier?
});

console.log('2: script end'); // 2

// Actual output:
// 1: script start
// 2: script end
// 3: promise 1
// 6: queueMicrotask
// 4: promise 2
// 5: setTimeout

Walked through step by step:

  1. The whole script is itself a macrotask. Lines 1 and 2 run synchronously — that's why script end lands before any callback.
  2. The setTimeout callback is handed to the browser's timer API. When the 0 ms elapses, the API will push a macrotask into the queue.
  3. The first .then registers a microtask straight away because the Promise is already resolved.
  4. queueMicrotask enqueues its callback right after the first .then, so the microtask queue holds [promise 1, queueMicrotask, ...].
  5. The synchronous portion finishes. The loop now drains microtasks. promise 1 runs and registers promise 2 as a new microtask. That new microtask is added to the end of the queue. So queueMicrotask runs before promise 2.
  6. Once the microtask queue is empty, the loop pulls the timer callback off the macrotask queue and prints 5: setTimeout.

The non-obvious lesson is line 6's position. Many developers expect queueMicrotask to run last because it's mentioned last in the source. It runs fourth because microtasks are FIFO and the second .then only becomes a microtask after the first one settles.

For the full HTML-spec definition of how microtasks interleave with rendering, see the WHATWG event loop section.


async/await under the hood

async/await is not a separate concurrency primitive. It's compiled to a Promise-returning function that uses each await as a "pause point." When the engine hits await, it does two things:

  1. Captures the rest of the function as a continuation.
  2. Hands the awaited value to Promise.resolve() and schedules the continuation as the .then callback.

That .then callback is a microtask. So await always yields to the microtask queue, even when the awaited value is already resolved.

async function fn() {
  console.log('2: before await'); // 2
  await Promise.resolve();
  console.log('4: after await'); // 4
}

console.log('1: start'); // 1
fn();
console.log('3: after fn'); // 3

// Output:
// 1: start
// 2: before await
// 3: after fn
// 4: after await

fn() runs synchronously up to the await. The line after await is scheduled as a microtask. The script continues, prints after fn, and only then drains microtasks — so after await lands last. This is also why await Promise.resolve() is the standard pattern for "yield to the microtask queue without doing anything."

A real consequence: await inside a loop is sequential by design. If you await a fetch for each item, the second fetch only starts after the first resolves. To run in parallel, build the array of promises first and await Promise.all(promises) once at the end.


requestAnimationFrame and where paint fits

requestAnimationFrame (rAF) callbacks live on a separate, browser-only schedule. The loop runs them once per frame, right before the browser paints — at the device's refresh rate, typically 60 Hz (every 16.7 ms) and 120 Hz on ProMotion / high-refresh displays.

Inside a single iteration the browser order is roughly:

  1. Run one macrotask.
  2. Drain all microtasks.
  3. If a paint is due, run all queued requestAnimationFrame callbacks.
  4. Style + layout + paint.
  5. Loop.

The implication for animation work: do your DOM reads and writes inside requestAnimationFrame, not inside a setTimeout. The timer fires whenever its turn comes; rAF fires aligned with the paint, so the next frame actually reflects your change. For an in-depth treatment of how rendering slots in between tasks, MDN's event-loop concepts page is the canonical reference.

A related API, requestIdleCallback, schedules low-priority work for when the main thread is idle — useful for analytics flushes — but is not implemented in Safari as of 2026, so most teams polyfill it with setTimeout.


Browser event loop vs Node.js event loop

Node.js shares the microtask rule with the browser — Promise callbacks and queueMicrotask drain between every task. What differs is the macrotask side. Node uses libuv, a C library that organises the macrotask queue into six phases that the loop walks through in order:

PhaseWhat runs there
TimerssetTimeout and setInterval callbacks whose threshold has elapsed
Pending callbacksDeferred OS-level callbacks (e.g. some TCP errors like ECONNREFUSED)
Idle / prepareInternal-only — libuv bookkeeping, never exposed to JavaScript
PollI/O callbacks (file system, network, sockets). The loop blocks here on epoll_wait / kqueue when nothing else is pending
ChecksetImmediate callbacks
Close'close' event handlers — e.g. socket.on('close', ...) after a destroy

Between every phase Node drains the microtask queue and process.nextTicknextTick first, then standard microtasks. Since libuv 1.45.0 (Node.js 20), timers run only after the poll phase rather than before and after — see the official Node.js event loop guide for the current shape.

setTimeout(fn, 0) vs setImmediate(fn) — the famous Node gotcha

// At the top level — ORDER IS NON-DETERMINISTIC
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

// Inside an I/O callback — immediate ALWAYS runs first
const fs = require('node:fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// Output inside the readFile callback:
// immediate
// timeout

The reason is the phase order. Inside an I/O callback the loop is already in the poll phase. The next phase is check (where setImmediate runs), and only then does the loop wrap around to the next iteration's timers phase. So setImmediate is guaranteed to win inside I/O. At the top level it's a race between process startup latency and the 1 ms timer threshold, and the winner is unpredictable.

process.nextTick — the highest-priority microtask

process.nextTick(fn) queues fn to run before the next microtask drain, regardless of phase. It is Node-only and predates queueMicrotask. The two are nearly equivalent today; most new code should reach for queueMicrotask because it's portable across browsers and Node. The danger of nextTick is recursion — a chain of nextTick callbacks that schedules more nextTick callbacks will starve the event loop entirely. The Node docs recommend setImmediate for any genuinely recursive deferral.

What about Bun and Deno?

Deno uses V8 and Tokio, with the same microtask semantics as Node, exposes setImmediate/process.nextTick for compatibility, and otherwise behaves like a browser-style loop.

Bun is written in Zig on top of JavaScriptCore (not V8) and uses its own event-loop implementation rather than libuv. The microtask rules are the same — that's a language-level spec, not a runtime detail — but the macrotask side is custom. Bun's HTTP server runs request parsing in native code, which is part of why it benchmarks 3–4× higher request-throughput than Node on raw I/O. For application code that follows the standard microtask/macrotask model, the runtime difference is invisible; for code that relies on Node-specific phase ordering (rare, but real), Bun's behaviour is not guaranteed to match.


Example 2 — mixing timers, promises, and process.nextTick in Node

This example assumes Node.js 20 or later. The comments number the exact print order.

// node example2.js  (Node.js 22 LTS)

console.log('1: top of script'); // 1

setTimeout(() => {
  console.log('7: setTimeout'); // 7
}, 0);

setImmediate(() => {
  console.log('8: setImmediate'); // 8
});

process.nextTick(() => {
  console.log('3: nextTick A'); // 3
  process.nextTick(() => {
    console.log('4: nextTick B (nested)'); // 4
  });
});

Promise.resolve().then(() => {
  console.log('5: promise'); // 5
  queueMicrotask(() => {
    console.log('6: queueMicrotask in then'); // 6
  });
});

console.log('2: bottom of script'); // 2

// Output:
// 1: top of script
// 2: bottom of script
// 3: nextTick A
// 4: nextTick B (nested)
// 5: promise
// 6: queueMicrotask in then
// 7: setTimeout
// 8: setImmediate

Why the order:

  1. Lines 1 and 2 are synchronous script execution — they always print first.
  2. Once the script finishes, Node drains the nextTick queue before any other microtasks. nextTick A runs and schedules nextTick B. The nextTick queue is fully drained, so B runs before any Promise callback — that's 3 and 4.
  3. Standard microtasks drain next. The Promise's .then callback fires (5) and registers a new microtask via queueMicrotask. That new microtask runs before the loop moves on (6).
  4. The synchronous turn is over. The loop enters the timers phase. The setTimeout(..., 0) callback fires (7).
  5. The loop walks to the check phase. setImmediate runs (8).

The 7-then-8 order is a top-level race in theory, but in practice Node's startup is fast enough that the 1 ms minimum timer threshold has typically elapsed by the time the loop reaches the timers phase — so setTimeout tends to win at the top level. Inside an I/O callback, setImmediate would always win. That conditional behaviour is the single most common Node interview trap.


Common pitfalls

setTimeout(fn, 0) is not 0 ms

The HTML spec mandates a 4 ms minimum for nested timeouts after a few layers, and even a single un-nested setTimeout(..., 0) carries the cost of one full event-loop turn. If you want "as soon as possible," use queueMicrotask. If you want "after the next paint," use requestAnimationFrame.

Microtask starvation

function spin() {
  queueMicrotask(spin);
}
spin();
// The page now hangs — no macrotask ever runs.

The microtask queue drains completely before macrotasks. A recursive microtask never lets the loop reach rendering, I/O, or user events. The page goes unresponsive. The same hazard exists with process.nextTick in Node. The fix is setImmediate (Node) or setTimeout(fn, 0) (browser) — both yield to macrotask land before continuing.

await inside a for loop

// Sequential — slow
for (const id of userIds) {
  const user = await fetchUser(id);
  process(user);
}

// Parallel — fast
const users = await Promise.all(userIds.map(fetchUser));
users.forEach(process);

Each await yields. A loop of 50 awaited fetches takes the sum of 50 round-trips, not the max. Use Promise.all when the operations are independent.

Forgetting that the script itself is a task

A common confusion: "I called setTimeout(..., 0) first, why does console.log after it run first?" Because the script you wrote is itself a macrotask. Every synchronous line in that script runs to completion before the loop is allowed to pick the next task. The timer callback can't fire until the script's task ends.

Mixing Promise.then and addEventListener ordering assumptions

DOM events are macrotasks. A click handler runs as one task; the Promise chain it kicks off resolves as microtasks during that handler's microtask drain. If a second click happens, its handler is queued as the next macrotask — after any microtasks scheduled by the first handler. Animation glitches in single-page apps often trace back to this assumption being wrong.


FAQ

What is the JavaScript event loop in one sentence?

It's the runtime mechanism that takes async callbacks off a queue and runs them one at a time on the single JavaScript thread, draining microtasks between each one.

Is setTimeout(fn, 0) really 0 ms?

No. The browser enforces a 1 ms baseline minimum and a 4 ms minimum for deeply nested timeouts. The callback also has to wait for the current call stack to empty and all microtasks to drain. Real-world latency is usually 4–10 ms even with a 0 argument.

What's the difference between queueMicrotask and process.nextTick?

queueMicrotask is the standardised cross-environment API — supported in every modern browser and in Node.js 12 and later. process.nextTick is Node-only and runs at a slightly higher priority than other microtasks, draining before the standard microtask queue on every phase boundary. For most code, queueMicrotask is the right default.

Are microtasks the same in the browser and Node.js?

Yes. The Promise spec defines microtask semantics at the language level, so Promise.then, queueMicrotask, and await behave identically. Where Node differs is the macrotask side — the six-phase libuv loop and the extra process.nextTick queue.

Why does await inside a loop run sequentially?

Each await pauses the function until the awaited Promise settles. The next loop iteration only starts once the previous one's continuation has run as a microtask. To run in parallel, collect the promises with .map(...) and await Promise.all(...) once at the end.

What's the difference between setImmediate and setTimeout(fn, 0) in Node?

Both are macrotasks but live in different phases — setImmediate in the check phase, setTimeout in the timers phase. Inside an I/O callback, setImmediate is guaranteed to fire first because check follows poll in the same iteration. At the top level the order is a race and isn't guaranteed.

Does Bun use the same event loop as Node?

No. Bun has its own event-loop implementation in Zig instead of libuv. The Promise / microtask rules are identical (they're spec-level), but the macrotask phase ordering and I/O dispatch are different. Most application code behaves identically; code that depends on Node-specific phase quirks may not.


Where Crosscheck fits

Event-loop bugs are some of the hardest to reproduce because the same code prints different orders depending on whether the page was hydrating, an animation frame was due, or the network resolved a microtask in between two ticks. Reproducing them after the fact means capturing the console, the network timeline, and the user's exact interaction — the kind of context that gets lost the moment the developer asks "what were you clicking?"

Crosscheck is a free Chrome extension that bundles screenshot, screen recording, full console output, and network requests into a single bug report sent to Jira, Linear, ClickUp, GitHub, or Slack. When a microtask ordering bug only repros once an hour, a Crosscheck recording is the difference between fixing it on the first try and chasing it for a week. For more on the debugging surface that surrounds the loop, the Crosscheck team has also written about JavaScript debugging tips, Chrome DevTools performance auditing, and how to debug a web application step by step.

Try Crosscheck free

Related Articles

Contact us
to find out how this model can streamline your business!
Crosscheck Logo
Crosscheck Logo
Crosscheck Logo

Speed up bug reporting by 50% and
make it twice as effortless.

Overall rating: 5/5