The event loop: tasks, microtasks, and why a then beats setTimeout(0)
One thread, a queue of tasks, and a microtask queue that drains completely after each one. Predict the order of any mix of timers, promises and awaits.
After this lesson you can
- Order synchronous code, microtasks and timer callbacks correctly
- Explain where the code after an await runs
- Explain how a chain of microtasks can starve timers and rendering
- Split long work into chunks that let timers run in between
JavaScript runs your code on one thread. While a function is running, nothing else in that program runs — no timer fires, no click handler, no promise callback. The event loop is what decides what runs next once the current code finishes.
Two queues
- Tasks (also called macrotasks): a timer firing, an I/O callback, a click, a message. The loop takes one task, runs it to completion, then moves on.
- Microtasks: promise callbacks (
then,catch,finally), the continuation of anasyncfunction after anawait, and anything passed toqueueMicrotask. After every task — and after the initial script — the loop runs every microtask in the queue, including any queued by those microtasks, before it takes the next task.
So a promise callback always runs before a setTimeout(fn, 0) scheduled
at the same moment: the timer is a new task, and the microtask queue
drains before the loop gets to it. Node adds one more queue,
process.nextTick: when a callback such as a timer finishes, Node runs
the nextTick queue first and then the promise microtasks. Browsers
render between tasks, never in the middle of the microtask queue.
export function run() { return new Promise((resolve) => { const log = []; setTimeout(() => { log.push("sync: start"); setTimeout(() => { log.push("task: setTimeout 0"); resolve(log); }, 0); Promise.resolve().then(() => log.push("microtask: then")); queueMicrotask(() => log.push("microtask: queueMicrotask")); process.nextTick(() => log.push("nextTick")); log.push("sync: end"); }, 0); });}The example schedules everything from inside a timer callback, an ordinary task, which is where most real code runs.
Where code after await runs
An async function runs synchronously up to its first await. At that
point it returns a pending promise to its caller, and the rest of the
function is queued as a microtask for when the awaited value settles —
even await null, which has nothing to wait for, gives up control until
the current synchronous code is done.
export function run() { return new Promise((resolve) => { const log = []; async function load() { log.push("load: before await"); await null; log.push("load: after await"); } setTimeout(() => { log.push("caller: start"); load(); Promise.resolve().then(() => log.push("caller: then")); log.push("caller: end"); setTimeout(() => resolve(log), 0); }, 0); });}Microtasks can starve everything else
Because the queue is drained completely, a microtask that keeps queueing
another microtask never lets the loop reach the next task. Timers wait,
I/O waits, and in a browser the page cannot repaint. A long loop of
synchronous work has the same effect. Splitting work into
setTimeout(..., 0) chunks yields to the loop between chunks; splitting
it into microtasks does not. The exercise does the first.
export function run() { return new Promise((resolve) => { let count = 0; setTimeout(() => resolve(`timer ran after ${count} microtasks`), 0); function spin() { count++; if (count < 10000) queueMicrotask(spin); } queueMicrotask(spin); });}Common mistakes
- Using
setTimeout(fn, 0)to mean "right after this". Promise callbacks and awaits queued at the same time all run first. - Assuming
awaitblocks the program. It suspends only its own function; the caller continues straight away (the second example). - "Yielding" with
await Promise.resolve()inside a long loop. It queues a microtask, so timers and rendering still wait for the whole loop. - Expecting
setTimeout(fn, 100)to run at exactly 100 ms. The delay is a request, not a schedule: the callback runs once the timer has expired and the thread is free, which can be much later.
What interviewers ask
- Predict the output of a snippet mixing
console.log,setTimeout(0)andPromise.resolve().then. — Synchronous logs first, then the promise callbacks, then the timeout. - What is the difference between a microtask and a macrotask? — One task runs per loop turn; after it, the whole microtask queue is drained, so microtasks always run before the next task.
- How would you keep a long computation from freezing the page? —
Split it into chunks scheduled as tasks (
setTimeout, aMessageChannel), or move it to a Web Worker; microtasks do not help.
Exercise · Pro
Long work that lets timers through
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade