Skip to main content
PRISM

The event loop

foundational · asked in almost every interview

One thread, three queues, and exactly one valid schedule. Watch a promise callback overtake a zero-delay timer, every single time.

Enumerating the interleavings…

The problem it solves

Every other page in this section is about a set of possible orderings. This one is about the absence of that set, and the contrast is the lesson.

Run the enumerator on any program on this page and the outcome space has exactly one member. Not “one likely outcome” or “one outcome in practice” — one, because the event loop’s discipline admits no choice at any point. There is nothing to interleave, so there is nothing to reason about.

That is what JavaScript engineers are really buying. Not the absence of races through good fortune, but a scheduler with no freedom. It is also what they pay for, on the blocking page, because a scheduler with no preemption cannot take the CPU back from you.

The mechanism

The loop has three places work can live and one rule for each:

  • The call stack: what is executing right now. It runs to completion. Nothing else can run while anything is on it — no preemption, no time slice, no interruption.
  • The microtask queue: promise continuations, queueMicrotask, MutationObserver. Drained completely after the current task finishes, and any microtask that queues another microtask extends the drain.
  • The macrotask queue: timers, I/O callbacks, events. Exactly one is taken per turn, and then the microtask queue is drained again before the next.

That is the whole algorithm. Finish what you started, drain every microtask, take one macrotask, repeat.

Every ordering question in JavaScript falls out of those three rules, including the one everybody gets wrong. setTimeout(fn, 0) schedules a macrotask. Promise.resolve().then(fn) schedules a microtask. Microtasks drain completely before the loop will look at the macrotask queue — so the promise callback runs first, always, regardless of which line appears earlier in the source. Zero milliseconds is not a request for “now”; it is a request for the next macrotask turn, and every pending microtask is ahead of it.

What the enumeration shows

The program registers a timer, registers a promise callback, and then does some synchronous work.

One schedule. One outcome. The recorded order is synchronous work first, then the promise callback, then the timer — syncAt == 1, promiseAt == 2, timerAt == 3 — and there is no second ordering for it to be compared against, because none exists.

Read that next to the lost update page’s twenty schedules and the difference in what the two execution models ask of you is stark. There, correctness required reasoning about which of twenty orderings could occur. Here, the order is a fact you can derive from three rules.

The blocking-the-loop variant is the same machinery showing what the guarantee costs: two ready requests queue behind one synchronous function, because there is no mechanism by which they could do otherwise.

The numbers worth carrying

  • The event loop’s outcome space: exactly 1 schedule.
  • Microtasks drain completely; macrotasks are taken one per turn. That asymmetry is the source of nearly every ordering surprise.
  • A microtask that queues a microtask is drained in the same pass. An infinite chain of them starves the macrotask queue forever — the page freezes, timers never fire, and no error is reported. This is the microtask equivalent of an infinite loop and is a real way to hang a browser tab.
  • await is a microtask boundary. The code after an await runs as a promise continuation, which is why it beats a setTimeout(…, 0) registered before it.

Where it breaks down

“One thread” is about JavaScript, not about the process. Node’s libuv keeps a thread pool for file I/O, DNS and crypto, and the browser runs compositing, network and workers off the main thread. What is single-threaded is your JavaScript, which is exactly enough to remove data races from your code and not at all the same as the process being single-threaded.

Workers reintroduce everything. SharedArrayBuffer between a worker and the main thread is genuine shared mutable memory, and Atomics exists precisely because the lost update becomes possible again. The freedom from races is a property of not sharing memory, not of having one loop.

Node and browsers differ in the details. Node has phases — timers, pending callbacks, poll, check, close — with setImmediate firing in the check phase and process.nextTick running before any promise microtask. Browsers have a simpler model plus rendering steps. Code that depends on the finer ordering is portable only by accident.

Run-to-completion is a correctness guarantee and a latency hazard. They are the same property seen from two sides, which is why the two pages are adjacent.

What people get wrong

“setTimeout(fn, 0) runs it immediately.” It runs it on the next macrotask turn, after every pending microtask, and after the current task finishes. Under load that can be many milliseconds.

“JavaScript is single-threaded so there are no concurrency bugs.” There are no data races. There are still races over logical state: two awaits that interleave and both act on a value read before either suspended is check-then-act with await as the window. The interleaving points are visible — they are exactly the awaits — which makes the bug findable rather than absent.

“async makes it parallel.” It makes it concurrent: several things in progress, one executing. See concurrency against parallelism.

“The microtask queue is a performance detail.” It determines ordering, which determines correctness whenever two callbacks touch the same state.

In production

The practical model to carry: your function will not be interrupted, and nothing else will run until it returns or awaits. Both halves matter. The first is why you can read and write module state without locks. The second is why a 200ms synchronous call is 200ms of unavailability for everything else.

The corollary for correctness is that every await is a place where the world can change. State read before an await may be stale after it, and two concurrent invocations of the same async function can interleave at those points. When an async function must not run twice concurrently, the fix is an explicit in-flight promise map — the same request-coalescing shape as cache stampede.

For debugging ordering, queueMicrotask and setTimeout are the two probes: if changing one to the other fixes your bug, the bug was an ordering assumption.

The follow-up questions

“What logs first: a promise callback or setTimeout(fn, 0) registered before it?” — The promise. Microtasks drain before the loop takes a macrotask. Being able to derive it from the three rules beats remembering it.

“How many possible orderings does this program have?” — One. That is the whole point of the model.

“Can JavaScript have a race condition?” — Not a data race in your code. Logical races across await boundaries, yes, and workers with SharedArrayBuffer bring the real thing back.

“What is the difference between process.nextTick and queueMicrotask?” — nextTick runs before promise microtasks in Node. A detail question, and knowing there is an ordering between them is the substance.

In an interview

The ordering question asked in almost every JavaScript interview, and the model that makes the answer derivable rather than memorised.

  • event loop
  • microtask
  • macrotask
  • JavaScript

Run these next

The rest of async and event loops