Medium JavaScript

JavaScript Event Loop: Microtasks vs Macrotasks, Predicting Output Order, and Starving the Loop

Sync code, then all microtasks, then one macrotask. Use that rule to predict Promise vs setTimeout order and to stop async loops from freezing Node.

25 Oct, 2026 11 min Updated 23 Oct, 2026 8 Code blocks
Diagram
flowchart TD A[Run synchronous code until the call stack is empty] --> B[Drain nextTick queue - Node only] B --> C[Drain microtask queue: promises, queueMicrotask] C --> D{More microtasks queued?} D -->|yes| C D -->|no| E[Browser may render a frame] E --> F[Take ONE macrotask: timer, I/O, message] F --> A

A teammate ships a "harmless" refactor: an export endpoint now builds a 200k-row report with a chain of async helpers instead of a plain loop. Nothing is awaited on the network, everything is in memory. Within an hour the health check starts timing out, p99 latency on unrelated endpoints jumps from 40 ms to 6 seconds, and the Kubernetes liveness probe restarts the pod. CPU looks busy but not pegged. The code has no infinite loop, no deadlock, and every function returns.

The culprit is the event loop. The refactor turned one long synchronous task into hundreds of thousands of chained microtasks, and in JavaScript microtasks always run before the loop is allowed to look at timers, sockets or the next HTTP request. If you understand exactly what runs when, bugs like this become obvious and output-order interview puzzles become boring. Here is the model I use.

One Thread, One Call Stack

JavaScript runs your code on a single thread with a single call stack. When you call a function, a frame is pushed; when it returns, the frame is popped. While that stack is non-empty, nothing else can run: no timer callback, no promise reaction, no click handler, no incoming request in Node. Every "concurrent" thing in JavaScript is really a queue of callbacks waiting for the stack to be empty.

The host (the browser or Node's libuv) does the actual waiting for timers, sockets and files on other threads or in the kernel. When something finishes, the host does not interrupt your code. It puts a callback into a queue. The event loop is just the policy that decides which queue gets served next once the stack is empty.

There are two families of queues that matter:

  • Macrotasks (the spec just calls them tasks): setTimeout, setInterval, setImmediate (Node), I/O callbacks, MessageChannel messages, UI events. The loop takes one of these per turn.
  • Microtasks: promise reactions (.then, .catch, .finally, and the continuation after await), queueMicrotask, and MutationObserver callbacks in browsers. After every macrotask, and after the initial script, the loop drains all of them, including microtasks queued while draining.

Node adds one more queue in front: process.nextTick callbacks, which are drained before promise microtasks.

How the Loop Picks the Next Callback

This is the whole algorithm, simplified enough to predict output and accurate enough to debug production:

flowchart TD A[Run synchronous code until the call stack is empty] --> B[Drain nextTick queue - Node only] B --> C[Drain microtask queue: promises, queueMicrotask] C --> D{More microtasks queued?} D -->|yes| C D -->|no| E[Browser may render a frame] E --> F[Take ONE macrotask: timer, I/O, message] F --> A

The key asymmetry is in steps C and D versus step F. Microtasks are drained until the queue is empty. Macrotasks are taken one at a time, with a full microtask drain after each. Browsers get a chance to render only between macrotasks, never in the middle of a microtask drain.

In Node, step F is more detailed because libuv cycles through phases (timers, pending callbacks, poll for I/O, check for setImmediate, close callbacks), but since Node 11 the rule that matters matches browsers: after each individual timer or immediate callback, Node drains nextTick and then the microtask queue.

Predicting Output Order

With that model, the classic puzzles are mechanical. Walk the script top to bottom, write sync output immediately, and put every callback in its queue.

console.log('A');

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

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

queueMicrotask(() => console.log('D'));

console.log('E');

// Output: A E C D B

A and E are synchronous. C and D are microtasks, queued in that order, so they run in that order as soon as the script finishes. B is a macrotask and has to wait for the microtask queue to be empty, even with a delay of 0 (Node clamps it to 1 ms anyway, and browsers clamp deeply nested timers to at least 4 ms).

async/await is just promises with nicer syntax. The body of an async function runs synchronously until the first await. Everything after the await is scheduled as a microtask once the awaited value settles:

async function load() {
  console.log(1);
  await null;            // everything below becomes a microtask
  console.log(2);
}

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

// Output: 0 1 4 2 3

1 prints synchronously inside load(). await null wraps null in a resolved promise and queues the continuation. Then .then queues 3. 4 prints, the stack empties, and the microtasks run in FIFO order: 2, then 3. Before V8 7.2 an await on a native promise took three microtask ticks instead of one, so very old Node versions could print 3 before 2. If code correctness depends on that kind of tick counting, the code is wrong, not the engine.

The third rule people miss: microtasks queued inside a macrotask run before the next macrotask.

setTimeout(() => {
  console.log('t1');
  Promise.resolve().then(() => console.log('p1'));
}, 0);

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

// Output (Node 11+ and browsers): t1 p1 t2

Node versions before 11 ran all expired timers first and drained microtasks afterwards, printing t1 t2 p1. That changed to match browsers, and it is one more reason to never write logic that depends on how many timers fire per phase.

Finally, Node's own queues:

// main.cjs (CommonJS)
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);

// Typical output: nextTick promise timeout immediate
// (timeout vs immediate order is NOT guaranteed in the main module)

nextTick beats promises because Node drains the tick queue first. setTimeout(fn, 0) versus setImmediate from the main module depends on how long process startup took relative to the 1 ms timer threshold, so it can flip between runs. Inside an I/O callback, setImmediate always fires first because the check phase comes right after poll. Also note: in an ES module, the top-level code itself runs as part of a promise job, so the promise vs nextTick order you observe there can differ from CommonJS. Another reason not to rely on it.

Starving the Loop

Because the microtask queue is drained until empty, a microtask that keeps scheduling microtasks blocks the loop forever, even though each individual callback is tiny:

// This never lets the timer fire: the microtask queue is never empty.
setTimeout(() => console.log('you will never see me'), 0);

function spin() {
  Promise.resolve().then(spin);
}
spin();

The call stack never grows, so you do not get a RangeError: Maximum call stack size exceeded. You just get a frozen tab or a Node process that stops answering requests while CPU sits at 100% on one core. Recursive process.nextTick does the same thing, even more aggressively, because it starves promises too.

The production incident from the intro is the softer version of this. Each await on an already-resolved value costs one microtask. A loop of 200k awaited helpers is 200k+ microtasks drained back to back, and from the event loop's point of view that is one enormous uninterruptible block. The fix is not "add more await". This does nothing for fairness:

// Looks like yielding, but is NOT: await on a resolved promise is a microtask,
// so the loop never reaches the timers or poll phase.
for (const row of hugeArray) {
  transform(row);
  await Promise.resolve();
}

To let the rest of the world run, you must yield a macrotask: setImmediate in Node, setTimeout(0) or MessageChannel in browsers, or scheduler.yield() where it is supported (Chromium-based browsers today).

Implementation: Time-Sliced Processing

The pattern I reach for is time slicing: do work until a budget is spent, then yield a macrotask so the loop can serve timers, I/O and requests, then continue. The clock is injectable so the behaviour is testable without real timing.

const { setImmediate: yieldToLoop } = require('node:timers/promises');

/**
 * Process a large array without blocking the event loop.
 * Runs `fn` on items until `budgetMs` is spent, then yields a macrotask
 * so timers, I/O callbacks and incoming requests get a turn.
 */
async function processInChunks(items, fn, { budgetMs = 10, now = () => performance.now() } = {}) {
  const results = [];
  let sliceStart = now();

  for (let i = 0; i < items.length; i++) {
    results.push(fn(items[i], i));

    if (now() - sliceStart >= budgetMs) {
      await yieldToLoop();   // a real macrotask, not a microtask
      sliceStart = now();
    }
  }

  return results;
}

module.exports = { processInChunks };

A budget of 5 to 15 ms per slice is a good starting point for a Node API server; it keeps per-request latency bounded while still doing real work per slice. In browsers aim for well under 16 ms so you do not drop frames, and consider moving truly heavy work to a Worker instead, where it cannot block the UI at all.

For CPU-bound jobs that take seconds, slicing is a band-aid. Use worker_threads in Node or a Web Worker in the browser, or push the job to a queue. Slicing shines for the middle ground: work that takes 50 to 500 ms and must stay on the main thread because it touches main-thread state.

Testing Event Loop Behaviour

You can test ordering with the built-in node:test runner. Record events into an array and assert on the sequence after giving the loop a real macrotask to finish. The second test proves that processInChunks really yields: a timer scheduled before the work must fire in the middle of the work, not after it. A fake clock makes the slice boundaries deterministic.

// event-loop.test.cjs  ->  node --test
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { processInChunks } = require('./process-in-chunks.cjs');

const tick = () => new Promise((resolve) => setTimeout(resolve, 5));

test('microtasks drain before the next timer', async () => {
  const log = [];
  setTimeout(() => log.push('timeout'), 0);
  Promise.resolve().then(() => log.push('promise'));
  queueMicrotask(() => log.push('microtask'));
  log.push('sync');

  await tick();
  assert.deepEqual(log, ['sync', 'promise', 'microtask', 'timeout']);
});

test('processInChunks lets a timer run in the middle of the work', async () => {
  const log = [];
  setTimeout(() => log.push('timer'), 0);

  let fakeTime = 0;
  const now = () => fakeTime;
  const items = Array.from({ length: 6 }, (_, i) => i);

  const out = await processInChunks(items, (x) => {
    fakeTime += 5;            // pretend each item costs 5 ms
    log.push(`item${x}`);
    return x * 2;
  }, { budgetMs: 10, now });

  assert.deepEqual(out, [0, 2, 4, 6, 8, 10]);
  const timerAt = log.indexOf('timer');
  assert.ok(timerAt > 0 && timerAt < log.length - 1, `timer ran at ${timerAt}`);
});

If someone replaces yieldToLoop() with await Promise.resolve(), the second test fails, because the timer would only run after all items are processed. That is exactly the regression you want caught in CI rather than in production.

Common Pitfalls

  • Assuming setTimeout(fn, 0) means "now". It means "after the current task, after all microtasks, and after at least 1 ms (4 ms when nested in browsers)". In an inactive browser tab it can be throttled to once per second or worse.
  • Using await as a yield point. Awaiting a resolved promise, a sync function, or null is a microtask. It never lets timers, I/O or rendering run.
  • Recursive process.nextTick or promise chains for "async recursion". They starve I/O. Use setImmediate for recursion that should be fair.
  • Expecting async functions to run in the background. The body runs synchronously until the first await. An async function with a CPU-heavy loop and no await blocks just as hard as a normal function.
  • Relying on cross-queue order like setTimeout vs setImmediate, or nextTick vs promises across module types. Express the dependency explicitly with await or callbacks instead.
  • Unhandled rejections in microtasks. A rejected promise without a handler surfaces as unhandledRejection, which crashes Node by default since v15. Microtask scheduling does not make errors disappear.
  • Measuring the wrong thing. Event loop lag is visible with perf_hooks.monitorEventLoopDelay(). If p99 delay is in the hundreds of milliseconds, look for long synchronous work or long microtask chains before blaming the database.

When to Care and When Not To

Care about the details when:

  • a Node service shows latency spikes on cheap endpoints while one expensive endpoint is running;
  • you write libraries that schedule callbacks (emitters, caches, stream adapters) and need a consistent "always async" contract (queueMicrotask is the right tool for that);
  • you process large in-memory datasets on the main thread, in Node or in the browser;
  • you debug flaky tests that pass or fail depending on timer timing.

Do not over-engineer when:

  • the work is I/O-bound and naturally awaits the network or disk; the loop is already free while waiting;
  • the job is truly CPU-heavy; move it to a worker or a queue instead of tuning slice sizes;
  • you are tempted to depend on exact tick counts. Write code whose correctness holds for any interleaving and use the event-loop model only to reason about latency and fairness.

The short version to keep in your head: sync code first, then all microtasks, then one macrotask, repeat. If something can schedule work faster than it finishes, it will starve everything behind it.

Interactive challenge

Try the challenge

Practice what you just learned. Write your solution, reveal hints if you get stuck.

Instructions

Build a tiny deterministic event loop simulator. Implement class MiniLoop with constructor(maxMicrotasksPerDrain = 10000) (Python: max_microtasks_per_drain) and a public `now` field starting at 0. queueMicrotask(fn) (Python: queue_microtask) appends fn to a FIFO microtask queue. setTimeout(fn, delay = 0) (Python: set_timeout) registers a timer due at now + delay, where a negative delay counts as 0, and returns a unique numeric id. drainMicrotasks() (Python: drain_microtasks) runs microtasks in FIFO order until the queue is empty, including microtasks queued while draining; if it would run more than maxMicrotasksPerDrain callbacks in one drain it must throw MicrotaskStarvationError (already declared). run(main) calls main() synchronously, drains microtasks, then repeatedly removes the timer with the earliest due time (ties broken by registration order), sets now to that due time, calls it, and drains microtasks again, until no timers remain. Timers registered from inside callbacks are due relative to the current now. Never use real timers.

Starter code

javascript
class MicrotaskStarvationError extends Error {}

class MiniLoop {
  constructor(maxMicrotasksPerDrain = 10000) {
    this.maxMicrotasksPerDrain = maxMicrotasksPerDrain;
    this.now = 0;
    this.microtasks = [];
    this.timers = [];
    this.seq = 0;
  }

  queueMicrotask(fn) {
    // TODO: append fn to the microtask queue
    throw new Error('TODO');
  }

  setTimeout(fn, delay = 0) {
    // TODO: register a timer due at this.now + delay (negative delay counts as 0),
    //       remember insertion order for ties, return a numeric id
    throw new Error('TODO');
  }

  drainMicrotasks() {
    // TODO: run microtasks FIFO until the queue is empty (including ones queued while draining);
    //       throw MicrotaskStarvationError if more than maxMicrotasksPerDrain run in one drain
    throw new Error('TODO');
  }

  run(main) {
    // TODO: call main(), drain microtasks, then repeatedly take the earliest timer
    //       (ties: insertion order), set this.now to its due time, run it, drain microtasks
    throw new Error('TODO');
  }
}
python
from typing import Callable


class MicrotaskStarvationError(Exception):
    pass


class MiniLoop:
    def __init__(self, max_microtasks_per_drain: int = 10000) -> None:
        self.max_microtasks_per_drain = max_microtasks_per_drain
        self.now = 0
        self.microtasks: list[Callable[[], None]] = []
        self.timers: list[tuple[int, int, Callable[[], None]]] = []
        self.seq = 0

    def queue_microtask(self, fn: Callable[[], None]) -> None:
        # TODO: append fn to the microtask queue
        raise NotImplementedError

    def set_timeout(self, fn: Callable[[], None], delay: int = 0) -> int:
        # TODO: register a timer due at self.now + delay (negative delay counts as 0),
        #       remember insertion order for ties, return a numeric id
        raise NotImplementedError

    def drain_microtasks(self) -> None:
        # TODO: run microtasks FIFO until empty (including ones queued while draining);
        #       raise MicrotaskStarvationError if more than max_microtasks_per_drain run in one drain
        raise NotImplementedError

    def run(self, main: Callable[[], None]) -> None:
        # TODO: call main(), drain microtasks, then repeatedly take the earliest timer
        #       (ties: insertion order), set self.now to its due time, run it, drain microtasks
        raise NotImplementedError

Your solution

Hints

All hints revealed
Hint 1

Store each timer with its due time and an increasing sequence number so ties keep registration order

Hint 2

Microtasks are drained after main() and after EVERY single timer callback, not once per batch of timers

Hint 3

Count how many microtasks ran in the current drain and throw before running one more than the limit

Solution · javascript
class MicrotaskStarvationError extends Error {}

class MiniLoop {
  constructor(maxMicrotasksPerDrain = 10000) {
    this.maxMicrotasksPerDrain = maxMicrotasksPerDrain;
    this.now = 0;
    this.microtasks = [];
    this.timers = [];
    this.seq = 0;
  }

  queueMicrotask(fn) {
    this.microtasks.push(fn);
  }

  setTimeout(fn, delay = 0) {
    const id = ++this.seq;
    this.timers.push({ id, fn, due: this.now + Math.max(0, delay) });
    return id;
  }

  drainMicrotasks() {
    let ran = 0;
    while (this.microtasks.length > 0) {
      if (ran >= this.maxMicrotasksPerDrain) {
        throw new MicrotaskStarvationError('microtask queue never drained');
      }
      const fn = this.microtasks.shift();
      ran++;
      fn();
    }
  }

  run(main) {
    main();
    this.drainMicrotasks();
    while (this.timers.length > 0) {
      let best = 0;
      for (let i = 1; i < this.timers.length; i++) {
        const t = this.timers[i];
        const b = this.timers[best];
        if (t.due < b.due || (t.due === b.due && t.id < b.id)) best = i;
      }
      const [timer] = this.timers.splice(best, 1);
      this.now = Math.max(this.now, timer.due);
      timer.fn();
      this.drainMicrotasks();
    }
  }
}
Solution · python
import heapq
from collections import deque
from typing import Callable


class MicrotaskStarvationError(Exception):
    pass


class MiniLoop:
    def __init__(self, max_microtasks_per_drain: int = 10000) -> None:
        self.max_microtasks_per_drain = max_microtasks_per_drain
        self.now = 0
        self.microtasks: deque[Callable[[], None]] = deque()
        self.timers: list[tuple[int, int, Callable[[], None]]] = []
        self.seq = 0

    def queue_microtask(self, fn: Callable[[], None]) -> None:
        self.microtasks.append(fn)

    def set_timeout(self, fn: Callable[[], None], delay: int = 0) -> int:
        self.seq += 1
        heapq.heappush(self.timers, (self.now + max(0, delay), self.seq, fn))
        return self.seq

    def drain_microtasks(self) -> None:
        ran = 0
        while self.microtasks:
            if ran >= self.max_microtasks_per_drain:
                raise MicrotaskStarvationError("microtask queue never drained")
            fn = self.microtasks.popleft()
            ran += 1
            fn()

    def run(self, main: Callable[[], None]) -> None:
        main()
        self.drain_microtasks()
        while self.timers:
            due, _, fn = heapq.heappop(self.timers)
            self.now = max(self.now, due)
            fn()
            self.drain_microtasks()

More from JavaScript