bastianplsfix

The event loop

Your code runs in one thread, one task at a time, and each task runs to completion before the next one starts. That is the whole model. A task is a piece of code with no arguments: the top level of your module, a timer's callback, the resolution of a promise. Tasks wait in a queue, and a loop takes them one at a time, which as pseudocode is the entire runtime:

while (true) {
const task = taskQueue.dequeue();
task();
}

Everything asynchronous in JavaScript is a consequence of those two sentences. Work that would otherwise block is handed to something outside your thread, and its result comes back as a later task; promises, async/await, and for await are all ways of writing code that will be resumed in a task other than the one it started in. This entry is the model, and the promises and async functions pages are how you work with it.

Create programs/the-event-loop.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import { assertEquals, assertStrictEquals } from "@std/assert";

Below the import, add one helper: nextTurn awaits a zero-delay timer, which parks the current code at the back of the task queue and resumes it on a later turn.

function nextTurn(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}

Why a timer rather than a resolved promise is itself one of this page's lessons, measured in the last step. Follow the page as you add and revise the runnable examples below the helper.

a scheduled task waits for the current one to finish

Deno.test("a scheduled task waits for the current one to finish", async () => {
const log: string[] = [];

log.push("first task starts");
setTimeout(() => log.push("second task"), 0);
log.push("first task ends");

assertEquals(log, ["first task starts", "first task ends"]);

await nextTurn();
assertEquals(log, ["first task starts", "first task ends", "second task"]);
});
Check programs/the-event-loop.test.ts
running 1 test from ./programs/the-event-loop.test.ts
a scheduled task waits for the current one to finish ... ok (5ms)

ok | 1 passed | 0 failed (6ms)

setTimeout(callback, 0) does not mean run this now. It means put this in the queue, and the queue is not consulted until the current task ends, so the callback runs after every remaining line of the current task, however many there are: the first assertion caught the log before the turn ended, and the callback had not run. The delay is a minimum rather than a promise, and zero means as soon as the queue gets to you.

two timers with one delay keep their order

Deno.test("two timers with one delay keep their order", async () => {
const log: string[] = [];

setTimeout(() => log.push("first"), 0);
setTimeout(() => log.push("second"), 0);

await nextTurn();
assertEquals(log, ["first", "second"]);
});
two timers with one delay keep their order ... ok (2ms)

The queue is a queue. Equal delays run in the order they were scheduled, which is worth knowing because it is the only ordering guarantee you get between two independent timers.

nothing can interrupt a running task

A timer set for zero milliseconds, then twenty milliseconds of spinning on the clock. The timer is long overdue, so predict the log:

Deno.test("nothing can interrupt a running task", () => {
const log: string[] = [];

setTimeout(() => log.push("timer fired"), 0);

const start = Date.now();
while (Date.now() - start < 20);

assertEquals(log, ["timer fired"]);
});
Check programs/the-event-loop.test.ts
running 3 tests from ./programs/the-event-loop.test.ts
...
nothing can interrupt a running task ... FAILED (26ms)

ERRORS

nothing can interrupt a running task => ./programs/the-event-loop.test.ts:32:6
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

+ [
+ "timer fired",
+ ]
- []

FAILURES

nothing can interrupt a running task => ./programs/the-event-loop.test.ts:32:6

FAILED | 2 passed | 1 failed (35ms)

error: Test failed

Twenty milliseconds late and still waiting. The callback cannot run: the current task is not finished, and there is nobody else to run it. This is called run to completion, and it is the single most useful thing to know about the model, with two consequences pulling in opposite directions. It makes JavaScript easy, because no task can observe another task's half-finished state, so no variable you touch can change underneath you and nothing you write needs a lock, which every other language with threads charges for. And it makes blocking total, because a slow synchronous loop does not slow things down, it stops them: no timer fires, no request is served, no promise settles. The spin is the honest illustration, and there is no reason to write one, as the never-spin step shows. Correct the prediction to [] and let the turn end:

Deno.test("nothing can interrupt a running task", async () => {
const log: string[] = [];

setTimeout(() => log.push("timer fired"), 0);

const start = Date.now();
while (Date.now() - start < 20);

assertEquals(log, []);

await nextTurn();
assertEquals(log, ["timer fired"]);
});
nothing can interrupt a running task ... ok (22ms)

microtasks all run before the next task

Four things scheduled from one task: a timer first, then two microtasks, then a synchronous line. Predict the order after the turn:

Deno.test("microtasks all run before the next task", async () => {
const log: string[] = [];

setTimeout(() => log.push("timer"), 0);
queueMicrotask(() => log.push("microtask"));
Promise.resolve().then(() => log.push("then"));
log.push("synchronous");

assertEquals(log, ["synchronous"]);

await nextTurn();
assertEquals(log, ["synchronous", "timer", "microtask", "then"]);
});
Check programs/the-event-loop.test.ts
running 4 tests from ./programs/the-event-loop.test.ts
...
microtasks all run before the next task ... FAILED (8ms)

ERRORS

microtasks all run before the next task => ./programs/the-event-loop.test.ts:47:6
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

[
"synchronous",
+ "timer",
"microtask",
"then",
- "timer",
]

FAILURES

microtasks all run before the next task => ./programs/the-event-loop.test.ts:47:6

FAILED | 3 passed | 1 failed (40ms)

error: Test failed

The timer was scheduled first and ran last. There are two queues rather than one, and promises use the smaller one: a microtask is scheduled by queueMicrotask or by settling a promise, and the microtask queue is drained completely after the current task and before the next task from the main queue. That ordering is not an implementation detail, it is specified, and it is why a promise chain feels tighter than a chain of timers. Correct the prediction:

Deno.test("microtasks all run before the next task", async () => {
const log: string[] = [];

setTimeout(() => log.push("timer"), 0);
queueMicrotask(() => log.push("microtask"));
Promise.resolve().then(() => log.push("then"));
log.push("synchronous");

assertEquals(log, ["synchronous"]);

await nextTurn();
assertEquals(log, ["synchronous", "microtask", "then", "timer"]);
});
microtasks all run before the next task ... ok (2ms)

a microtask can queue another, and the timer still waits

Deno.test("a microtask can queue another, and the timer still waits", async () => {
const log: string[] = [];

setTimeout(() => log.push("timer"), 0);
Promise.resolve()
.then(() => log.push("micro 1"))
.then(() => log.push("micro 2"))
.then(() => log.push("micro 3"));

await nextTurn();
assertEquals(log, ["micro 1", "micro 2", "micro 3", "timer"]);
});
a microtask can queue another, and the timer still waits ... ok (2ms)

Drained completely includes microtasks queued by microtasks. Each .then in the chain schedules the next one, and all three run before the timer that was scheduled first. Which means a long enough chain of already-settled promises can starve the timer queue indefinitely. In practice this rarely bites, because a real chain waits on real work and each wait is a break, but it is the reason use-a-promise-instead-of-a-timer is not automatically the answer for splitting up a long job, and the last step of this page measures exactly that.

await is where other code gets to run

Deno.test("await is where other code gets to run", async () => {
const log: string[] = [];

async function counted(): Promise<void> {
log.push("body starts");
await 0;
log.push("body resumes");
}

const running = counted();
log.push("caller continues");
await running;

assertEquals(log, ["body starts", "caller continues", "body resumes"]);
});
await is where other code gets to run ... ok (60µs)

An async function's body starts synchronously, runs until the first await, and then returns to its caller, with what follows the await arriving as a microtask; the operand 0 is not a promise and the function pauses anyway, from the async functions page's await accepts anything, and always pauses. The point here is structural: every await in your code is a place where other code runs. That is the one limit on the comfort of run to completion. Within a task nothing changes underneath you; across an await everything can, which is why a value you validated before one may not be the value you use after it.

a timer handle is opaque, and in Deno it is not a number

The web platform says a timer handle is a number, and a great deal of code annotates it as one:

Deno.test("a timer handle is opaque, and in Deno it is not a number", () => {
const handle: number = setTimeout(() => {}, 100);
clearTimeout(handle);
});
Check programs/the-event-loop.test.ts
TS2322 [ERROR]: Type 'Timeout' is not assignable to type 'number'.
const handle: number = setTimeout(() => {}, 100);
~~~~~~
at file:///programs/the-event-loop.test.ts:94:9

error: Type checking failed.

In Deno the handle is a Timeout object, for compatibility with Node, so the annotation does not compile, and typeof confirms it at run time. Nothing about the handle is documented for you to use, so the fix is to stop naming its type: const handle = setTimeout(...) infers correctly and works everywhere, and if you must write the type down, ReturnType<typeof setTimeout> says what you mean without committing to a platform. The cleared timer's callback throws on purpose, and the test passing is the proof it never ran:

Deno.test("a timer handle is opaque, and in Deno it is not a number", () => {
const handle = setTimeout(() => {
throw new Error("never runs");
}, 100);

assertStrictEquals(typeof handle, "object");

clearTimeout(handle);
});
a timer handle is opaque, and in Deno it is not a number ... ok (113µs)

what "concurrent" means here, and what it does not

One thread means nothing in your code runs at the same time as anything else in your code: two async functions that appear to run together are interleaving at their await points, not executing simultaneously. The concurrency is in the work you delegated, because a file read, a network request, or a hash happens outside your thread and genuinely in parallel with it, and what comes back is a task. Real parallelism inside JavaScript needs a worker, a separate thread with its own everything and message passing between the two, where the only memory they can share is a SharedArrayBuffer, which the buffers and views page introduces. So when you read that JavaScript is single-threaded but concurrent, both halves are true and they are about different things: your code is single-threaded, and the work it waits on is not. No test on this step, because sameness of a moment is not something an assertion can watch; the log-based interleavings above are as close as the language lets you look.

never spin

Deno.test("never spin", async () => {
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

const log: string[] = [];
setTimeout(() => log.push("timer during sleep"), 0);

await sleep(5);
assertEquals(log, ["timer during sleep"]);
});
never spin ... ok (6ms)

Three lines, and it is the only sleep anyone should write. During it, timers fire, requests are served, and promises settle, which is what the log proves: the pending timer ran in the middle of the wait, where the spin two steps up held the same shape of timer hostage for twenty milliseconds.

break a long job into tasks

Deno.test("break a long job into tasks", async () => {
const interleaved: string[] = [];
setTimeout(() => interleaved.push("other work"), 0);

let total = 0;
for (let batch = 0; batch < 3; batch++) {
for (let i = 0; i < 1000; i++) total += i;
await nextTurn();
}

assertStrictEquals(total, 1498500);
assertEquals(interleaved, ["other work"]);

const starved: string[] = [];
setTimeout(() => starved.push("waiting"), 0);

for (let batch = 0; batch < 3; batch++) {
await Promise.resolve();
}
assertEquals(starved, []);

await nextTurn();
assertEquals(starved, ["waiting"]);
});
break a long job into tasks ... ok (10ms)

Two measurements, and the helper's design pays off. The batched loop hands control back between batches, so the pending timer ran during the first nextTurn and the total still arrived. The second loop makes the same shape with await Promise.resolve(), and the timer starved: three turns of microtasks came and went with waiting still queued, because a microtask break never reaches the task queue. That is why nextTurn is a timer rather than a resolved promise, deliberately, and why splitting a long job needs a real task boundary.

In practice