Engineering · Interactive ·
The JavaScript Event Loop: Predict the Output
Guess what this code logs, then step through the call stack and queues. Puzzles, a live stepper, a real async bug and a page-freeze demo.
Listen to this article
9 sections, one clip each
Contents
TL;DR
JavaScript runs one thing at a time. The order of your logs depends on the call stack, the microtask queue and the task queue, and that order explains a lot of real bugs.
A spinner that lies
A common bug. Imagine a form with a "Save all" button. You click it. The spinner shows for a blink, then the page says "Saved". But nothing is saved yet. Three requests are still on their way.
The code after the loop did not wait for them. It ran first. The next screen reads the data, and the data is not there.
To understand why, let's play. Each puzzle shows some code. Click the console lines in the order you expect, then check. Go with your gut first. A wrong guess teaches you more than a right one.
Puzzle 1: What does this log?
JavaScript
console.log('1')setTimeout(() => console.log('2'), 0)Promise.resolve().then(() => console.log('3'))console.log('4')Click the console lines in the order you expect them to appear.
What just happened
Step through it. Press Next, or use the arrow keys, and watch the lists change.
Code
console.log('1')setTimeout(() => console.log('2'), 0)Promise.resolve().then(() => console.log('3'))console.log('4')Call stack
- empty
Microtask queue
- empty
Task queue
- empty
Console
- empty
Nothing has run yet. JavaScript starts at the first line and runs one thing at a time.
Three things matter:
- The call stack holds the function that is running right now. JavaScript runs one thing at a time, and a running function is never interrupted.
- The microtask queue holds promise callbacks (
.then, and the code afterawait) and anything you pass toqueueMicrotask. - The task queue holds callbacks like timers and events.
Here is the rule. After the current code is done and the call stack is empty, the browser runs all microtasks. Only then does it take the next task.
So setTimeout(fn, 0) loses against .then. Not because it is slow. Because it waits in a different queue.
Puzzle 2: async and await
Same game, now with async and await. Be careful: the function body does not run in one piece.
JavaScript
async function load() {console.log('A')await nullconsole.log('B')}console.log('C')load()console.log('D')Click the console lines in the order you expect them to appear.
Code
async function load() { console.log('A') await null console.log('B')}console.log('C')load()console.log('D')Call stack
- empty
Microtask queue
- empty
Task queue
- empty
Console
- empty
Nothing has run yet. Declaring the function logs nothing, so the first call is on line 6.
An async function runs like a normal function until its first await. At the await it pauses, and the caller carries on. The rest of the function is scheduled as a microtask. That is why B comes last.
Remember this sentence: the code after await is a microtask.
Puzzle 3: Microtasks that queue microtasks
One more. Watch the order of the microtasks, and what happens to the one that is queued inside another.
JavaScript
setTimeout(() => console.log('timeout'), 0)queueMicrotask(() => console.log('micro 1'))Promise.resolve().then(() => {console.log('micro 2')queueMicrotask(() => console.log('micro 3'))})console.log('sync')Click the console lines in the order you expect them to appear.
The browser does not take the next task until the microtask queue is empty, and that includes microtasks queued by other microtasks.
That has a cost. A microtask that keeps queueing new microtasks means the queue is never empty. The browser never reaches the next task, and it never gets to update the screen. The page freezes, and there is no long loop anywhere in the code.
Back to the spinner
Here is the bug from the start.
async function saveAll(items: Item[]) {
setSaving(true)
items.forEach(async item => {
await save(item)
})
setSaving(false)
showToast('Saved')
}forEach calls your async callback for every item. Each call runs until its first await and returns a promise. forEach does not look at those promises. It does not wait for them. So the lines after it run right away.
Run it three ways. The saves are fake: timers of 400, 700 and 300 ms. The numbers are measured on your machine, so expect a few milliseconds of noise.
items.forEach(async item => { await save(item) })
Spinner off: – · All saves done: –
for (const item of items) { await save(item) }
Spinner off: – · All saves done: –
await Promise.all(items.map(save))
Spinner off: – · All saves done: –
forEach turns the spinner off after about 0 ms, while the saves are still running. for...of with await waits for each save in turn, so it takes about 1400 ms. Promise.all starts all three saves at once and takes about as long as the slowest one, about 700 ms.
Two ways to fix it:
setSaving(true)
- items.forEach(async item => {
- await save(item)
- })
+ // Fix A: one after the other
+ for (const item of items) {
+ await save(item)
+ }
+
+ // Fix B: all at once (use A or B, not both)
+ await Promise.all(items.map(item => save(item)))
setSaving(false)
showToast('Saved')Use for...of when the order matters, or when a save needs the result of the one before. Use Promise.all when the saves are independent. It is faster. Know that it rejects as soon as one save fails.
Feel the main thread
The counter below counts animation frames. It is driven by requestAnimationFrame, which runs your callback before the next repaint. While it ticks, the page is alive.
Frames drawn: 0
Press the first button. A long loop is one long task. The browser can only update the screen between tasks, so the counter freezes.
Press the second button. It is the same work, cut into small pieces. After each piece the code gives the thread back with scheduler.yield(), or with setTimeout(fn, 0) when the browser does not have it. Between the pieces the browser can draw, and the counter keeps going.
Tip: Keep tasks short
A screen at 60 Hz needs a new frame about every 16 ms. If one task takes longer, the page cannot update on time. Split the work into chunks, or move it to a Web Worker.
Warning: setTimeout(fn, 0) is not instant
The callback still waits for the current code and for every microtask. Then it waits for its turn in the task queue. The delay is what you ask for, not a promise: the real delay can be longer.
Check yourself
Three questions. No score, just an explanation each.
Takeaways
- JavaScript runs one thing at a time. A running function is never interrupted.
- After the current code, the browser runs all microtasks before it takes the next task.
.then, the code afterawaitandqueueMicrotaskare microtasks. Timers and events are tasks.forEachdoes not wait for async callbacks. Usefor...oforPromise.all.- The screen updates between tasks. Keep tasks short, or the page freezes.
Cheat sheet
- The order: synchronous code, then all microtasks, then the next task.
- Microtasks:
Promise.then,catchandfinally, the code afterawait,queueMicrotask. - Tasks:
setTimeout,setInterval, and events like clicks. - In loops:
for...ofwaits for eachawait,Promise.allruns them together,forEachdoes not wait at all.