Module 12 · Async 1: Callbacks and the Event Loop
The Event Loop, the Picture: One Thread, a Stack and Two Queues
In this lesson
- Explain why a callback given to
setTimeoutruns only after the current code has finished. - Picture the call stack, the host's timers, the task queue and the microtask queue working together.
- Predict the print order of a small program that mixes plain logs, timers and microtasks.
Rafi builds a quiz app. When a student clicks "Submit", he wants a message to appear at once, then the grading to start. So he writes setTimeout(showMessage, 0), thinking zero means "now". Then the grading loop runs, and the message appears only after it. His zero-millisecond timer waited the whole time.
Rafi's timer did not break. It did exactly what JavaScript promises. A timer's callback never cuts into code that is already running. It waits until the stack is empty, and then the event loop hands it over. This module is about that loop. Once you can see it, the print order of any async program stops being a surprise.
One thread, one call stack
JavaScript runs your code on one thread. A thread is one line of work: it does one thing, then the next. There is no second worker in your program that could run a timer's callback while your loop is busy.
That one thread keeps a call stack. Module 5 showed it: each function call pushes a frame, and each return pops one. The main script itself is the bottom frame. While anything is on the stack, the thread is busy.
const start = Date.now();
setTimeout(() => {
const waited = Date.now() - start;
console.log("timer ran, waited 200 ms or more:", waited >= 200);
}, 0);
let spins = 0;
while (Date.now() - start < 200) spins++;
console.log("loop done");
loop done
timer ran, waited 200 ms or more: true
The timer asked for 0 ms. The loop kept the stack busy for 200 ms, so "loop done" prints first. Only then does the timer's callback run, and the second line confirms it waited the whole 200 ms.
The host does the waiting
If JavaScript has one thread, who counts the milliseconds? Not your code. The host does: the program that runs JavaScript. In a browser the host is the browser itself; on our Playground and on servers it is Node.js.
The host offers APIs that wait for things: timers, file reads, network replies, clicks. In a browser they are called the Web APIs. Node has its own set. When you call setTimeout(cb, 100), you hand cb to the host and get control back at once. setTimeout itself takes almost no time.
console.log("before");
const id = setTimeout(() => console.log("callback"), 100);
console.log("setTimeout returned, the timer id is an object:", typeof id === "object");
console.log("after");
before
setTimeout returned, the timer id is an object: true
after
callback
setTimeout returns at once with a timer id, and the script keeps going. In Node that id is an object; in a browser it is a number. Lesson 02 uses the id to cancel a timer.
The task queue
When the host's timer is done, the callback cannot run straight away, because the stack might be busy. So it joins a line: the task queue, also called the macrotask queue or callback queue. Timers, finished file reads and click events all wait there, first in, first out.
A task is one callback from that queue, run from start to finish. Nothing interrupts a task. That is the "run to completion" rule, and it is why Rafi's loop could not be cut into.
The microtask queue
There is a second, smaller line with a higher priority: the microtask queue. You can put a callback there with queueMicrotask(cb). A promise's .then(cb) also puts its callback there. Module 13 teaches promises properly; here a then is simply "a microtask".
Here is why it has priority. After each task, and after the main script, the loop runs every microtask before the next task. That includes microtasks that other microtasks queue while it works.
The loop: one rule, three steps
Now the whole picture fits in three steps, repeated forever:
- Run the current task until the stack is empty. The first task is the main script.
- Run every microtask, oldest first, until the microtask queue is empty.
- Take the oldest task from the task queue, if there is one, and go back to step 1.
That is the event loop. When both queues are empty and no timer or read is pending, Node has nothing left to wait for, and the program ends.
Watch it on the classic puzzle. The widget runs the program with real timers and real microtasks, and records each moment. The program's own output appears at the bottom, line by line, in the order Node produced it.
Look at the moment the script ends. The timer is still with the host, and both microtasks are waiting. Step 2 of the loop runs them both. Only after that does the timer's callback reach the task queue and the stack. The last frame shows Node's real order: start, end, then, micro, timeout.
The event loop at a glance
setTimeout(callback, ms); // the host waits ms, then callback joins the TASK queue
queueMicrotask(callback); // callback joins the MICROTASK queue
promise.then(callback); // a microtask too (Module 13)
// the loop, forever:
// 1. run the current task until the stack is empty (first task: the main script)
// 2. run every microtask, new ones included
// 3. take the oldest task, go to 1
Lima wants to see the order with her own words. The program logs, queues a microtask and sets a timer, twice each, in a mixed order.
setTimeout(() => console.log("timer A"), 0);
console.log("sync 1");
queueMicrotask(() => console.log("micro A"));
setTimeout(() => console.log("timer B"), 0);
queueMicrotask(() => console.log("micro B"));
console.log("sync 2");
sync 1
sync 2
micro A
micro B
timer A
timer B
The two plain logs print while the script runs. Then step 2 runs both microtasks in the order they were queued. Then the loop takes the timers, A before B, one task each. The order inside each queue is the order of the calls.
Run in CompilerThe loop does not stop at the microtasks that were waiting when the script ended. New ones join the same run.
setTimeout(() => console.log("timer"), 0);
queueMicrotask(() => {
console.log("micro 1");
queueMicrotask(() => console.log("micro 2, queued by micro 1"));
});
console.log("script done");
script done
micro 1
micro 2, queued by micro 1
timer
"micro 2" was queued while microtasks were already running. It still runs before the timer, because step 2 continues until the microtask queue is truly empty.
Run in CompilerWhere this is used
- Google Chrome: every tab runs its page's JavaScript on one main thread, and a long script there freezes scrolling and clicks until it finishes.
- Node.js: a Node web server answers many requests on one thread, because it hands every database or file wait to the host.
- Visual Studio Code: its extensions run in a separate Node process, so one slow extension does not freeze typing.
Common mistakes
1. Reading zero as now
let ready = false;
setTimeout(() => {
ready = true;
}, 0);
console.log("ready:", ready);
ready: false
The program prints ready: false. The timer's callback cannot run until the script has finished, so the log runs first. A zero delay means "as soon as the stack is free", never "right here".
2. Waiting for a timer with a busy loop
let done = false;
setTimeout(() => {
done = true;
}, 10);
const start = Date.now();
while (!done && Date.now() - start < 300) {}
console.log("done after the loop:", done);
done after the loop: false
The loop waits for done to turn true, but it is the thing keeping the stack busy. The callback cannot run until the loop gives up, so done stays false. Without the 300 ms escape this program would never end.
Lima writes a main script with three kinds of lines. log X is console.log("X"). timeout X is setTimeout(() => console.log("X"), 0). micro X is queueMicrotask(() => console.log("X")). She wants to know the order the words appear in.
Input. The first line holds n. Each of the next n lines holds one command, log, timeout or micro, and a word.
Output. The n words, one per line, in the order Node prints them.
Constraints. 1 <= n <= 1000; Every word is 1 to 10 lowercase letters or digits..
Sample.
6
timeout tea
log one
micro milk
log two
timeout cake
micro sugar
one
two
milk
sugar
tea
cake
const input = require("fs").readFileSync(0, "utf8");
const tokens = input.split(/\s+/).filter(Boolean);
let at = 0;
const next = () => tokens[at++];
const nextInt = () => Number(next());
const out = [];
// your code: read with next() and nextInt(), push every line of output to out
console.log(out.join("\n"));
Run in Compiler
Graded as queue-order, a free problem in this module's problem set.
Bob sets one timer at time 0, then his script keeps the stack busy. The callback runs once the timer is due and the stack is free. Node treats a delay below 1 as 1.
Input. q, then q questions, each a delay d and the time s at which the script ends, in ms.
Output. one line per question: the time the callback starts.
Constraints. 1 <= q <= 1000. 0 <= d, s <= 1000000.
Sample. the input below.
4
100 30
0 0
20 500
5 5
const input = require("fs").readFileSync(0, "utf8");
const tokens = input.split(/\s+/).filter(Boolean);
let at = 0;
const next = () => tokens[at++];
const nextInt = () => Number(next());
const out = [];
// your code: read with next() and nextInt(), push every line of output to out
console.log(out.join("\n"));
Sample output.
100
1
500
5
Run in Compiler
Zara's friends send guesses for her puzzles. A puzzle is a main script of log, timeout and micro lines, each with a word, as in Exercise 1. A guess lists the words in some order.
Input. n, then n lines of the script. Then the n words of the guess on one line.
Output. right if the guess is Node's order, or else wrong at and the first position, counted from 1, where it differs.
Constraints. 1 <= n <= 1000. Words are 1 to 10 lowercase letters and are all different.
Sample. the input below.
4
timeout cat
log dog
micro owl
log ant
dog ant cat owl
const input = require("fs").readFileSync(0, "utf8");
const tokens = input.split(/\s+/).filter(Boolean);
let at = 0;
const next = () => tokens[at++];
const nextInt = () => Number(next());
const out = [];
// your code: read with next() and nextInt(), push every line of output to out
console.log(out.join("\n"));
Sample output.
wrong at 3
Run in Compiler
Common doubts
If there is only one thread, how does a file read happen at the same time as my code?
The reading is done by the host, outside your JavaScript. Node uses the operating system and a few helper threads of its own for that. Your code only sees the callback, and that callback runs on your one thread. Lesson 05 names the parts.
Is the task queue really one queue?
In the browser there can be several task sources, such as timers and clicks, and the browser picks between them. Node splits the loop into phases with their own queues. For everyday code, "one line of tasks, first in, first out" predicts the right order. Lesson 05 shows where it is more detailed.
Why does Node print the timer id as an object?
In Node,
setTimeoutreturns aTimeoutobject with methods such asrefandunref. Browsers return a plain number. Both are only meant to be passed toclearTimeout.When does a Node program end?
When the stack and both queues are empty and nothing is pending with the host: no timer, no open file or socket. That is why a program with an active
setIntervalnever ends by itself.
Key takeaways
- JavaScript runs on one thread with one call stack, and a running task is never interrupted.
setTimeouthands the callback to the host and returns at once; the host does the waiting.- A finished timer's callback joins the task queue;
queueMicrotaskandthenuse the microtask queue. - The loop: finish the task, run every microtask, then take the next task.
- A zero delay means "when the stack is free", never "now".
- Go deeper: Under the Hood, libuv, Node's Loop Phases, nextTick, setImmediate and a Starved Loop (Pro).
Next: every piece of the toolkit, one by one: setTimeout's real guarantee, setInterval, clearing, and the callback shapes.
End of lesson 1
Mark it done, and your progress moves with you.
Next: Every Piece of the Async Toolkit: Timers, Clearing, Microtasks and Callback Shapes