The JavaScript Event Loop, Microtasks vs Macrotasks (with Timing Examples)

Link copied
The JavaScript Event Loop, Microtasks vs Macrotasks (with Timing Examples)

The JavaScript Event Loop, Microtasks vs Macrotasks (with Timing Examples)

JS Tutorial Module 5: Async Lesson 5.5

JavaScript is single-threaded but routinely handles thousands of network requests, timers, and DOM events without blocking. The trick behind this is the event loop — the runtime mechanism that coordinates async work around the single call stack.

If you have ever wondered why Promise.resolve().then(...) always runs before setTimeout(..., 0), or why a tight loop freezes your UI, the answer is the event loop. This article unpacks it.

This is Part 7 of the JavaScript Fundamentals series. You will get more out of it after reading Part 1 on execution contexts and the call stack — the event loop is the mechanism that feeds that call stack.

The shape of the runtime #

Three pieces work together at runtime:

  ┌────────────────────────────────┐
  │  Call Stack                    │  ← what's running RIGHT NOW (one thing)
  └────────────────────────────────┘
               ▲
               │ event loop pulls
               │
  ┌────────────┴───────────────────┐
  │  Microtask Queue               │  ← Promises, queueMicrotask, MutationObserver
  ├────────────────────────────────┤
  │  Macrotask Queue (Task Queue)  │  ← setTimeout, setInterval, I/O, UI events
  └────────────────────────────────┘
  • Call stack — synchronous code runs here. Always finishes before anything async kicks in.
  • Microtask queue — high-priority work scheduled by Promise.then, await, queueMicrotask, and MutationObserver.
  • Macrotask queue — lower-priority work scheduled by setTimeout, setInterval, network I/O (in Node), DOM events, and MessageChannel.

The event loop is a tiny algorithm:

  1. If the call stack is empty, drain the entire microtask queue.
  2. Run one macrotask.
  3. After that macrotask completes, drain the microtask queue again.
  4. (Browsers) Render if needed.
  5. Repeat.

That is it. The whole asynchronous JavaScript world is that loop.

Why microtasks always win #

The key word is drain. After every macrotask, the loop drains every microtask before running another macrotask. That is why:

setTimeout(() => console.log('macro'), 0);
Promise.resolve().then(() => console.log('micro'));
console.log('sync');

// Logs:
// sync
// micro
// macro

Walk it:

  1. setTimeout schedules a macrotask (queued for >= 0ms).
  2. Promise.resolve().then(...) schedules a microtask.
  3. console.log('sync') runs — synchronous code on the stack.
  4. The stack empties. Event loop checks microtasks → finds the promise callback. Logs 'micro'.
  5. Microtask queue empty. Loop pulls the next macrotask → the timeout callback. Logs 'macro'.

Even with setTimeout(..., 0) — "as soon as possible" — microtasks still run first. The 0ms is a minimum, and the timer's callback is a macrotask either way.

A trickier example #

console.log('1');

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

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

console.log('5');

Order?

  1. console.log('1') — sync.
  2. setTimeout schedules macrotask.
  3. First .then schedules microtask.
  4. console.log('5') — sync. (The second .then does NOT schedule a microtask yet — it's chained to a promise that hasn't resolved its callback.)
  5. Stack empty. Drain microtasks.
  6. Run first promise callback → logs '3'. The second .then now fires because its parent has resolved → adds another microtask.
  7. Microtask queue is checked again before exit — runs the new microtask → logs '4'.
  8. Microtask queue empty. Pull macrotask. → logs '2'.

Final: 1 5 3 4 2.

The non-obvious step is 7: microtasks scheduled by other microtasks are processed in the same drain. This is why an infinite microtask chain (e.g., function tick(){ Promise.resolve().then(tick) }) freezes the browser — the event loop never gets to run a macrotask or repaint.

await is just .then in disguise #

When you await something, the JavaScript engine suspends the function and schedules the continuation as a microtask:

async function run() {
  console.log('a');
  await Promise.resolve();
  console.log('b');
}
run();
console.log('c');

// Logs:
// a
// c
// b

The code before await runs synchronously. The code after is a microtask scheduled when the awaited value resolves. That is why b comes after c (which is sync) but before any setTimeout callbacks.

For more on async/await internals, see our two posts: Unlocking Async/Await Beyond Syntactic Sugar and Deciphering the Elegant Syntactic Sugar of Async/Await.

What counts as a macrotask? #

In the browser:

  • setTimeout, setInterval callbacks
  • DOM events (click, keydown, load)
  • XMLHttpRequest / fetch event callbacks
  • MessageChannel.postMessage recipients
  • requestIdleCallback

In Node.js, the picture is more nuanced — there are multiple macrotask phases (timers, pending callbacks, idle/prepare, poll, check, close) plus setImmediate (which is a separate macrotask category). But the microtask drain rule still applies between phases.

What counts as a microtask? #

  • Promise then / catch / finally callbacks
  • await continuations
  • queueMicrotask(fn) — explicit API for scheduling one
  • MutationObserver callbacks (in the browser)
  • Node.js process.nextTick — actually a separate, higher-priority queue, but lumped with microtasks for most mental models

process.nextTick (Node-only) is even more aggressive #

In Node:

process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
// Logs: nextTick, promise

process.nextTick callbacks drain before any promise microtasks. Use it sparingly — abusing it can starve the rest of the event loop.

Rendering and the event loop (browser) #

In the browser, the event loop also coordinates rendering:

┌─────────────────────────────────┐
│ 1. Run one macrotask            │
├─────────────────────────────────┤
│ 2. Drain microtask queue        │
├─────────────────────────────────┤
│ 3. Run requestAnimationFrame    │
├─────────────────────────────────┤
│ 4. Layout + Paint (if needed)   │
└─────────────────────────────────┘

Key implications:

  • Long synchronous work blocks rendering. A 200ms blocking loop means no frame drawn for 200ms.
  • Microtasks block rendering too. If you keep scheduling microtasks, you delay step 3 forever.
  • requestAnimationFrame fires right before paint — perfect for animations.
  • DOM measurements should happen in requestAnimationFrame — that's when layout is fresh.

This is why a for (let i = 0; i < 1e9; i++) {} loop freezes the UI — and why offloading to a Web Worker (or breaking work across setTimeout(..., 0) chunks) keeps the page interactive. See our post on Web Workers and Service Workers for the parallelism story.

Practical pattern: breaking up long work #

If you have a long CPU-bound task (large array processing, JSON serializing huge objects), break it into chunks separated by setTimeout(..., 0) so the event loop can render and process events between chunks:

function processChunked(items, chunkSize = 100) {
  let i = 0;
  function next() {
    const end = Math.min(i + chunkSize, items.length);
    for (; i < end; i++) heavyWork(items[i]);
    if (i < items.length) setTimeout(next, 0);
  }
  next();
}

Better alternative in modern browsers: scheduler.postTask() (with priority levels), or a Web Worker for genuinely heavy work.

Pattern: yield to the event loop with await #

A neat trick — yield to the event loop in async code by awaiting a resolved promise:

async function process(items) {
  for (let i = 0; i < items.length; i++) {
    heavyWork(items[i]);
    if (i % 100 === 0) await new Promise(r => setTimeout(r, 0));
    // ↑ lets the event loop run one full cycle (macrotask + microtasks + render)
  }
}

Note await Promise.resolve() alone is NOT enough — that just schedules a microtask and the drain runs immediately without yielding to rendering. You need a real macrotask (setTimeout 0, MessageChannel, or scheduler.yield()).

Common gotchas #

Gotcha 1: Microtask starvation.

function tick() { Promise.resolve().then(tick); }
tick();  // freezes the page

The microtask drain runs until empty. A self-scheduling promise chain never empties.

Gotcha 2: setTimeout(fn, 0) is NOT zero. Browsers clamp nested timeouts to 4ms after 5 levels. The minimum first-level delay is ~1ms. For "as soon as possible", use queueMicrotask(fn) (microtask) or MessageChannel (macrotask without timer clamping).

Gotcha 3: Order across promises feels random until you internalize the microtask drain. If two promises resolve at the same time, their then callbacks run in the order they were registered. Within one microtask drain, callbacks run in FIFO order.

Gotcha 4: Awaiting in a loop is sequential.

for (const url of urls) {
  await fetch(url); // serial!
}

For parallel, gather promises first:

await Promise.all(urls.map(u => fetch(u)));

Our post on sequential API calls covers when you actually want each pattern.

Recap #

  • The event loop = one call stack + microtask queue + macrotask queue, coordinated by a tiny algorithm.
  • Rule: the microtask queue is fully drained between every macrotask.
  • That is why Promise.then always runs before setTimeout(0).
  • await continuations are microtasks. Code before await is sync.
  • Browsers also run rendering steps in the loop — long sync work or runaway microtasks freeze the UI.
  • For long CPU-bound work, yield via setTimeout 0, scheduler.yield(), or a Web Worker.

Next up: Promises Under the Hood — we build a Promise from scratch in 60 lines and watch how then, microtask scheduling, and chaining actually work.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *