Lesson 02 / 17
The Event Loop
The distinction between the call stack, the task queue, and the microtask queue; the event loop's turn rule; justifying a mixed example's output order line by line.
Contents
The previous lesson said callbacks “get in queue.” There is not one queue, and the work inside it is not equal in priority. A promise callback runs before a timer callback registered before it; this is not a matter of priority, it is a difference of queue.
This lesson establishes those queues and the rule between them. Once the rule is learned, a mixed program’s output order becomes something you can state before running it — this is the base skill used throughout the rest of this course.
The Call Stack
The call stack is the structure holding the function calls currently running. The
call frame concept introduced in the Programming Fundamentals course applies here
exactly as it was: every call pushes a frame, every return pops one.
Once the stack is empty, there is no currently running work left. This is the exact statement of the previous lesson’s run-to-completion rule: the next piece of work only starts once the stack is empty.
This has an observable consequence. A try block only catches errors that occur while
its own stack frame is alive. A callback, on the other hand, runs on an empty stack, in
an entirely different turn:
process.on("uncaughtException", (error) => { console.log("2 — caught at the runtime level:", error.message); }); try { setTimeout(() => { throw new Error("from inside the callback"); }, 0); console.log("1 — try block finished without issue"); } catch (error) { console.log("this line never runs:", error.message); }
1 — try block finished without issue 2 — caught at the runtime level: from inside the callback
The try block covers only the setTimeout call itself; it does not cover the
callback’s body. By the time the callback runs, the try block’s frame has long since
left the stack. This observation will explain, later in the course, why asynchronous
error handling needs a separate mechanism.
The Task Queue and the Event Loop
The host environment places completed work into the task queue: a timer whose duration has elapsed, an incoming network response, a clicked button. Every entry in the queue is a task — in some sources, macrotask — meaning a callback to be run start to finish.
The event loop is the control flow that repeats two steps forever:
- If the stack is empty, take one task from the task queue and run it to completion.
- Once the task is done, fully drain the microtask queue.
These two steps are called a turn. In a browser environment, a render step also falls between turns; this is why a long task also delays drawing.
The Microtask Queue
A microtask is a small piece of work guaranteed to run at the end of the same turn.
Promise callbacks and work registered with queueMicrotask enter this queue.
The difference between the two queues can be stated in one sentence: one task is taken from the task queue per turn; the microtask queue is drained completely.
New microtasks added during the draining are included in that same draining. That is, if a microtask produces another microtask, that one also runs before moving on to the next task.
setTimeout(() => { console.log("A — first task"); queueMicrotask(() => console.log("B — first task's microtask")); setTimeout(() => console.log("D — task produced by the first task"), 0); }, 0); setTimeout(() => console.log("C — second task"), 0);
A — first task B — first task's microtask C — second task D — task produced by the first task
The first task produced both a microtask and a task. The microtask ran before moving to the second task; the produced task, since it was added to the end of the queue, stayed for last.
Order in a Mixed Example
The rule is tested in an example where synchronous code and both queues appear together. You should be able to state the following program’s output order before running it.
console.log("1 — synchronous"); setTimeout(() => console.log("6 — task queue (setTimeout)"), 0); Promise.resolve().then(() => console.log("4 — microtask (promise)")); queueMicrotask(() => console.log("5 — microtask (queueMicrotask)")); console.log("2 — synchronous"); (function () { console.log("3 — synchronous (inner function)"); })();
1 — synchronous 2 — synchronous 3 — synchronous (inner function) 4 — microtask (promise) 5 — microtask (queueMicrotask) 6 — task queue (setTimeout)
The reasoning, line by line:
- 1, 2, 3 — These are the running work itself. No queue is looked at until the
stack is empty. The
setTimeout,then, andqueueMicrotaskcalls among these lines only register; they do not run the function they register. - 4, 5 — The stack is empty. The microtask queue is drained completely. The two
pieces of work inside it run in registration order: the promise callback first, then
queueMicrotask. - 6 — The microtask queue is empty. Only now is a task taken from the task queue.
The source of the order is not priority itself, it is queue structure: setTimeout,
even though registered first, stayed for last — because it went into a different
queue.
The Microtask Queue Can Starve the Other
The “drained completely” rule has a cost: a microtask chain that keeps re-registering itself keeps the task queue waiting indefinitely.
let count = 0; setTimeout(() => console.log("timer ran; accumulated microtasks:", count), 0); function chain() { count += 1; if (count < 100000) queueMicrotask(chain); } queueMicrotask(chain);
timer ran; accumulated microtasks: 100000
All one hundred thousand microtasks ran before the zero-delay timer. Had the counter’s limit been removed, the timer would never have run at all. This is called starvation: one queue consumes the other’s chance to run.
The practical consequence is this: splitting a long, divisible piece of work into microtasks is not a fix. To actually give the host environment a turn, the work has to be handed to the task queue. This pattern will be detailed in the performance topic.
Applying the Rule to the Measurement Stream
The rule works exactly the same way in the course’s example. The program below requests measurements from two stations; the stations’ response delays differ, and once a response arrives, a recording task is left to a microtask.
const SOURCE = { A1: { delay: 30, value: 21.4 }, B2: { delay: 10, value: 19.8 }, }; function requestMeasurement(station, callback) { console.log("request registered:", station); const record = SOURCE[station]; setTimeout(() => callback({ station, value: record.value }), record.delay); } requestMeasurement("A1", (measurement) => { console.log("task: processing response —", measurement.station); queueMicrotask(() => console.log("microtask: record complete —", measurement.station)); }); requestMeasurement("B2", (measurement) => { console.log("task: processing response —", measurement.station); queueMicrotask(() => console.log("microtask: record complete —", measurement.station)); }); console.log("script's synchronous part finished");
request registered: A1 request registered: B2 script's synchronous part finished task: processing response — B2 microtask: record complete — B2 task: processing response — A1 microtask: record complete — A1
Two observations matter. First, request order and response order are not the same: A1 was requested first, B2 responded first. In an asynchronous flow, the order results arrive in does not depend on the order requests were started. Second, each response’s recording microtask runs at the end of its own turn, before the next response is processed; the two stations’ processing steps never get mixed up with each other.
Queue Details Are Environment-Dependent
The two-queue model is standard and holds in every environment. Runtimes, however, can split the task queue into phases and add their own extra queues. Where these extra queues sit relative to promises’ microtask queue can vary by context, even within the same runtime.
The two files below contain the same three lines; only their module form differs. Module forms themselves are the subject of the Modules, Tooling and the Ecosystem course.
// order.cjs Promise.resolve().then(() => console.log("promise microtask")); process.nextTick(() => console.log("runtime-specific queue")); console.log("synchronous");
$ node order.cjs synchronous runtime-specific queue promise microtask
// order.mjs Promise.resolve().then(() => console.log("promise microtask")); process.nextTick(() => console.log("runtime-specific queue")); console.log("synchronous");
$ node order.mjs synchronous promise microtask runtime-specific queue
The relative order of the two queues flipped. What should be drawn from this is not which form is correct: portable code should not rely on the relative order of non-standard queues. The three-phase order among synchronous code, microtasks, and tasks is reliable in every environment; beyond that is the environment’s own detail.
Summary
- The call stack holds the running calls; the next piece of work only starts once the stack is empty.
- On every turn, the event loop takes one task from the task queue, then fully drains the microtask queue.
- Microtasks run in the same turn, tasks in later turns; this is why a promise callback registered later runs before a timer registered earlier.
- Microtasks produced during the draining are included in that same draining; this can starve the task queue.
- The three-phase order (synchronous code, microtasks, tasks) is standard; where runtime-specific extra queues sit is not portable.
Next Step
Now that the queue rule is established, we can return to the most common tool that puts
work in the queue: timers. The next lesson shows why the number inside setTimeout is
a lower bound rather than a promise, what order equal-delay timers enter, and how
interval scheduling drifts once its own callback runs long. The stream’s first concrete
piece — a fake station driven by a timer — will be built there.
To keep your progress and take notes, Log in
My notes
Log in to take notes.