The event loop

Intermediate45 min

Learn how one thread runs many tasks, the order of synchronous code, microtasks and timers, and how to schedule and cancel timers.

What you will learn
  • Explain run-to-completion, the call stack and the two kinds of queue
  • Predict the order of synchronous code, promise callbacks, queueMicrotask and timers
  • Schedule and cancel work with setTimeout, setInterval and setImmediate
  • Recognise code that blocks the loop and break it into smaller pieces
On this page

A JavaScript program runs on one thread, yet it can wait for a file, a network reply and a timer at the same moment. The mechanism that makes this work is the event loop. You met its effects in the beginner level: callbacks in higher-order functions, a timer in debounce, and a first await in dynamic import. This lesson opens the box, and the next seven lessons build on it.

One thread, run to completion

Your JavaScript runs on a single thread, which can do one thing at a time. The call stack records which function is running and where to return to. A function that starts running finishes before any other code runs. This is called run-to-completion: a running function cannot be preempted, and it runs entirely before any other code runs (MDN: JavaScript execution model).

That is a pleasant guarantee: two callbacks that both change a variable cannot interleave in the middle of a statement. The price is that while your code runs, nothing else can.

Waiting is not done by your code. When you ask for a timer, or a file, the runtime (here Node.js) does the waiting outside your JavaScript. When the wait is over, it puts a job on a queue. The event loop takes jobs from the queues one at a time and runs each to completion on the stack.

There are two kinds of queue that matter for you:

  • The task queues hold callbacks from timers, I/O and similar sources.
  • The microtask queue holds promise callbacks (then, catch, finally, and the code after an await) and callbacks passed to queueMicrotask.

The rule is this: when the stack is empty, the runtime runs every microtask, including any that the microtasks add, and only then takes the next task. Microtasks therefore have priority over tasks: the microtask queue is always drained before the next task is taken.

The order of a simple program

Here is a program that uses all of them. The numbers in the messages are the order in which they print.

el_order.mjs
console.log("1 sync start");
setTimeout(() => console.log("5 timer, 0 ms"), 0);
Promise.resolve().then(() => console.log("3 promise callback"));
queueMicrotask(() => console.log("4 microtask"));
console.log("2 sync end");
Output
1 sync start
2 sync end
3 promise callback
4 microtask
5 timer, 0 ms

The two synchronous console.log calls run first, because they are part of the job that is already on the stack. The promise callback and the queueMicrotask callback are microtasks, so they run as soon as that job ends. The timer is a task. Even with a delay of 0, it waits for the next turn of the loop, after the microtasks.

Promise.resolve().then(...) and queueMicrotask(...) put work on the same microtask queue, in the order you call them. queueMicrotask is the plain way to say “run this soon, but not right now”. A promise is the plain way to say “run this when this value is ready”, which the next lessons cover.

Timers: setTimeout

setTimeout(callback, delay, ...args) asks the runtime to call callback after at least delay milliseconds. Extra arguments are passed to the callback. It returns a Timeout object, and clearTimeout cancels a timer that has not fired yet.

el_timers.mjs
setTimeout(() => console.log("30 ms timer"), 30);
setTimeout(() => console.log("10 ms timer"), 10);
setTimeout((a, b) => console.log("20 ms timer", a, b), 20, "x", "y");
console.log("all three scheduled");
const cancelled = setTimeout(() => console.log("never runs"), 5);
clearTimeout(cancelled);
Output
all three scheduled
10 ms timer
20 ms timer x y
30 ms timer

The timers fire in the order of their delays, not the order you wrote them. The cancelled timer never runs. And the main program prints “all three scheduled” before any timer fires, because the whole script is one job.

The delay is a lower bound, not an appointment. The callback runs once the delay has passed and the loop gets to it, and the operating system or other callbacks can make that later (Node.js: timers in the event loop). Node also treats a delay below 1, or NaN, as 1 millisecond and prints a warning for a negative or NaN delay. So setTimeout(fn, 0) means “as soon as the loop gets to timers”, not “now”.

Repeating work: setInterval

setInterval(callback, delay) calls the callback again and again, every delay milliseconds, until you call clearInterval with the value it returned. If you forget, a program with an active interval never ends by itself.

el_interval.mjs
let ticks = 0;
const id = setInterval(() => {
ticks += 1;
console.log("tick", ticks);
if (ticks === 3) {
clearInterval(id);
console.log("stopped");
}
}, 10);
console.log("interval started");
Output
interval started
tick 1
tick 2
tick 3
stopped

The interval callback is a closure over ticks and id. It stops itself the third time. This is the same shape as the debounce helper in the beginner lessons, which used clearTimeout to cancel a pending call.

Microtasks run until none are left

Microtasks can add more microtasks. The loop does not move on to the next task until the whole microtask queue is empty.

el_chain.mjs
setTimeout(() => console.log("timer"), 0);
let n = 0;
function more() {
n += 1;
console.log("microtask", n);
if (n < 3) queueMicrotask(more);
}
queueMicrotask(more);
console.log("sync done");
Output
sync done
microtask 1
microtask 2
microtask 3
timer

The timer was registered first, yet all three microtasks run before it. A microtask that keeps scheduling itself without end would starve every timer and every I/O callback, because the loop never gets to the next task.

process.nextTick

Node has a second, older queue. process.nextTick(callback) runs the callback after the current operation and before the event loop continues. Node marks it Legacy (since Node 22.7) and recommends queueMicrotask instead (process.nextTick). You will see it in older code. In a CommonJS file, nextTick callbacks run before promise callbacks and queueMicrotask callbacks.

el_nexttick_cjs.cjs
const { nextTick } = require("node:process");
Promise.resolve().then(() => console.log("promise callback"));
queueMicrotask(() => console.log("queueMicrotask"));
nextTick(() => console.log("nextTick"));
console.log("sync");
Output
sync
nextTick
promise callback
queueMicrotask

The same program as an ES module prints a different order.

el_nexttick.mjs
import { nextTick } from "node:process";
Promise.resolve().then(() => console.log("promise callback"));
queueMicrotask(() => console.log("queueMicrotask"));
nextTick(() => console.log("nextTick"));
console.log("sync");
Output
sync
promise callback
queueMicrotask
nextTick

In an ES module, nextTick comes last, because an ES module is itself evaluated inside the microtask queue, so the microtasks go first. The same four lines give two different orders depending on the kind of file. Use queueMicrotask, which behaves the same way everywhere.

setImmediate and the order of phases

setImmediate(callback) runs the callback after the I/O callbacks of the current turn of the loop. Callbacks queued with it run in the order you created them. One that is queued from inside another immediate callback waits for the next turn.

How does that compare with setTimeout(fn, 0)? From the main module, the order of the two is not deterministic, because it depends on how fast the process starts. Inside an I/O callback, the immediate always runs first. This example reads its own file and compares the two inside the read callback:

el_immediate.mjs
import { readFile } from "node:fs";
readFile(import.meta.filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
console.log("callback done");
});
Output
callback done
immediate
timeout

Blocking the loop

Because every job runs to completion, a job that takes a long time keeps everything else waiting: timers, I/O, and every other callback. A long loop is the simplest way to see it.

el_blocking.mjs
const start = Date.now();
setTimeout(() => {
console.log("timer ran, late:", Date.now() - start >= 50);
}, 10);
while (Date.now() - start < 50) {
// busy: nothing else can run
}
console.log("loop finished");
Output
loop finished
timer ran, late: true

The timer asked for 10 milliseconds. The busy loop kept the thread for 50, and the timer could only run afterwards.

The fix for long work is to cut it into pieces and give the loop a chance to run other things between them. setImmediate is a convenient way to schedule the next piece. In this example another task is already waiting when the work starts:

el_chunks.mjs
setImmediate(() => console.log("other task"));
function work(from) {
console.log("chunk starting at", from);
if (from < 4) {
setImmediate(() => work(from + 2));
}
}
work(0);
Output
chunk starting at 0
other task
chunk starting at 2
chunk starting at 4

The “other task” ran between the first and the second chunk. For heavy computing, a better answer is a separate thread, which the advanced level covers under worker threads. To find out where a blocked loop spends its time, the advanced level also covers profiling.

Common pitfalls

  • Treating setTimeout(fn, 0) as “immediately”. It runs after the current job and after all microtasks.
  • Expecting a timer to be exact. The delay is a minimum.
  • Forgetting clearInterval. The program keeps running for ever.
  • Relying on the order of two timers with the same delay, or on setTimeout(fn, 0) against setImmediate in the main module.
  • Long synchronous loops. Nothing else runs while they do.
  • Endless microtask chains. A microtask that keeps adding microtasks never lets the loop continue.
  • Relying on process.nextTick ordering. Prefer queueMicrotask.

Exercises

ExercisePredict the order

Without running it, write down the order in which this program prints its eight letters. Then run it and compare. The letters follow the order of the code, not the order of the output.

el_predict.mjs
console.log("A");
setTimeout(() => {
console.log("B");
queueMicrotask(() => console.log("C"));
}, 0);
setTimeout(() => console.log("D"), 5);
Promise.resolve()
.then(() => console.log("E"))
.then(() => console.log("F"));
queueMicrotask(() => console.log("G"));
console.log("H");
Show solution

The output is:

A
H
E
G
F
B
C
D

The synchronous lines print first: A, then H. The microtasks follow in the order they were queued: the first promise callback E, then the queueMicrotask callback G. E returned, which queued F at the back of the queue, so F comes after G. The timers are tasks, and tasks wait until the microtask queue is empty. The timer B runs first because its delay is shorter. The microtask it queued, C, runs right after it and before the next timer D: the loop empties the microtask queue after every task.

ExerciseReminders you can cancel

Write a function later(ms, message) that prints message after ms milliseconds and returns a function that cancels the reminder. Schedule reminders “first” at 10 ms, “second” at 20 ms and “third” at 30 ms, and cancel the second one before it fires. Print scheduled three, cancelled one right after scheduling.

Expected output:

scheduled three, cancelled one
first
third
Show solution

later returns a small closure that calls clearTimeout on the timer it created, so the caller never sees the timer object.

el_reminders.mjs
function later(ms, message) {
const id = setTimeout(() => console.log(message), ms);
return () => clearTimeout(id);
}
later(10, "first");
const cancelSecond = later(20, "second");
later(30, "third");
cancelSecond();
console.log("scheduled three, cancelled one");
Output
scheduled three, cancelled one
first
third
ExerciseKeep a 10 ms timer on time

The program below does 100 ms of work in one block, so a timer that asked for 10 ms has to wait. The helper busy(ms) keeps the thread busy for ms milliseconds. Change the work so that it runs in ten pieces of 10 ms, with setImmediate between them, and the timer fires before the work is finished. Use the same output lines.

el_ex_starter.mjs
const busy = (ms) => {
const end = Date.now() + ms;
while (Date.now() < end) {
// busy: nothing else can run
}
};
let finished = false;
setTimeout(() => console.log("timer fired, work finished:", finished), 10);
for (let i = 0; i < 10; i++) busy(10);
finished = true;
console.log("work done");
Output
work done
timer fired, work finished: true

The timer waited until the work was over. Expected output after your change:

timer fired, work finished: false
work done
Show solution

The loop becomes a function that does one piece and schedules the next one with setImmediate. After the first piece, 10 ms have passed, so the timer is due. The loop reaches the timers before the immediate callback, so the timer runs between the first and the second piece, while finished is still false.

el_ex_chunks.mjs
const busy = (ms) => {
const end = Date.now() + ms;
while (Date.now() < end) {
// busy: nothing else can run
}
};
let finished = false;
setTimeout(() => console.log("timer fired, work finished:", finished), 10);
function step(i) {
busy(10);
if (i < 9) {
setImmediate(() => step(i + 1));
} else {
finished = true;
console.log("work done");
}
}
step(0);
Output
timer fired, work finished: false
work done

Summary

  • JavaScript runs one job at a time and each job runs to completion. Waiting is done outside your code, which is handed a job when the wait ends.
  • After the current job, the loop runs all microtasks (promise callbacks, queueMicrotask) before the next task (timer or I/O callback).
  • setTimeout and setInterval take a minimum delay, not an exact one. Cancel them with clearTimeout and clearInterval.
  • setImmediate runs after the I/O callbacks of the current turn. process.nextTick is legacy, and its order against promises differs between CommonJS files and ES modules.
  • A long synchronous job blocks everything. Cut the work into pieces.

Next: callbacks, the first way JavaScript code handled “later”, and why people moved on from them.