Back to blog
Jun 2026·3 min readAI-generated

Understanding the JavaScript Event Loop

#javascript#async#performance

JavaScript runs on a single thread, yet somehow handles thousands of concurrent network requests, timers, and UI events without blocking. The event loop is the mechanism that makes this possible, and once you can predict its ordering, a whole category of "why did this run before that?" bugs stops being mysterious.

One thread, one call stack

JavaScript can only execute one piece of code at a time. Every function call pushes a frame onto the call stack, and the stack must empty out before the engine looks at anything else. That's the part most people already know. What trips people up is everything that happens around the stack: timers, promises, I/O callbacks, and DOM events don't run immediately when they're ready — they get queued, and the event loop decides when to pull them off the queue and run them.

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

This logs 1, 4, 3, 2 — not 1, 2, 3, 4. Understanding why requires knowing that there isn't one queue, there are (at least) two.

Two queues, not one

Macrotasks (also called the "task queue") include setTimeout, setInterval, I/O callbacks, and UI events. Microtasks include promise callbacks (.then, .catch, .finally) and queueMicrotask.

The event loop's rule: after the currently running script finishes and the call stack is empty, drain the entire microtask queue — including any new microtasks added while draining it — before running even one macrotask.

That's why 3 printed before 2 above: Promise.resolve().then(...) queues a microtask, setTimeout(..., 0) queues a macrotask, and microtasks always win, no matter how small the macrotask's delay is.

function logOrder() {
  console.log('start');

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

  Promise.resolve()
    .then(() => console.log('microtask 1'))
    .then(() => console.log('microtask 2'));

  console.log('end');
}

logOrder();
// start
// end
// microtask 1
// microtask 2
// macrotask

Both chained .then() calls run before the setTimeout callback, even though it was scheduled with a 0ms delay — the microtask queue is fully drained first.

Why setTimeout(fn, 0) doesn't mean "now"

A 0 delay doesn't mean "run immediately." It means "run as soon as possible, but only after the current call stack is empty and the microtask queue is drained." Browsers also clamp nested timeouts to a minimum of 4ms after a few levels of nesting. If you're using setTimeout(fn, 0) to "defer" work, that's fine — just don't expect it to interleave with synchronous or microtask code.

A practical case: why your UI update is late

function expensiveWork() {
  let sum = 0;
  for (let i = 0; i < 1e9; i++) sum += i;
  return sum;
}

showLoadingSpinner(); // updates the DOM
const result = expensiveWork(); // blocks the thread
hideLoadingSpinner();

The spinner never actually appears. showLoadingSpinner() schedules a DOM update, but the browser can't paint until the call stack is empty — and expensiveWork() keeps the stack busy the whole time. The fix is to yield back to the event loop before doing the heavy work, so the browser gets a chance to paint:

function showSpinnerThenWork() {
  showLoadingSpinner();

  setTimeout(() => {
    const result = expensiveWork();
    hideLoadingSpinner();
    renderResult(result);
  }, 0);
}

Now the spinner update makes it into a paint before expensiveWork() starts. For genuinely long-running work, a better fix is moving it off the main thread entirely with a Web Worker — setTimeout(fn, 0) only helps the first paint, it doesn't stop the loop from blocking again once expensiveWork() starts running.

async/await is microtask sugar

async functions don't introduce new concurrency primitives — they're built entirely on promises and the microtask queue. Every await suspends the function and schedules its continuation as a microtask once the awaited value resolves.

async function sequence() {
  console.log('a');
  await null;
  console.log('b');
}

console.log('before');
sequence();
console.log('after');

// before
// a
// after
// b

await null still yields to the microtask queue, even though null is already a "resolved" value — the continuation after await never runs synchronously, no matter what you're awaiting.

Starving the event loop with microtasks

Because the microtask queue is drained completely before the next macrotask runs, a microtask that keeps scheduling more microtasks can starve rendering and I/O indefinitely:

function recurse() {
  Promise.resolve().then(recurse);
}
recurse(); // freezes the tab — the loop never reaches a macrotask

This is a real footgun in recursive promise chains — a .then() callback that unconditionally re-queues itself will lock up the page, whereas the equivalent with setTimeout would at least let the browser breathe between iterations.

Takeaway

The event loop's ordering isn't arbitrary: synchronous code runs first, the entire microtask queue drains next, then one macrotask runs, then the microtask queue drains again, and so on. Promises and async/await live in the microtask lane; timers, I/O, and UI events live in the macrotask lane. Once you know which lane a given piece of code is in, you can predict the order it runs in — no more guessing.

NewerWriting Your First GitHub ActionOlderDebugging Memory Leaks in Node.js