---
title: 'The Event Loop'
source: 'https://academia.sh/en/courses/asynchronous-javascript/event-loop'
course: 'Asynchronous JavaScript and the Runtime'
language: en
updated: '2026-08-23T07:00:57+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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:

```js
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:

1. If the stack is empty, take one task from the task queue and run it to completion.
2. 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.

```js
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.

```js
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`, and `queueMicrotask` calls 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.

```js
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.

```js
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.

```js
// 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
```

```js
// 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.
