The event loop
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 anawait) and callbacks passed toqueueMicrotask.
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.
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");1 sync start2 sync end3 promise callback4 microtask5 timer, 0 msThe 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.
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);all three scheduled10 ms timer20 ms timer x y30 ms timerThe 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.
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");interval startedtick 1tick 2tick 3stoppedThe 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.
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");sync donemicrotask 1microtask 2microtask 3timerThe 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.
const { nextTick } = require("node:process");
Promise.resolve().then(() => console.log("promise callback"));queueMicrotask(() => console.log("queueMicrotask"));nextTick(() => console.log("nextTick"));console.log("sync");syncnextTickpromise callbackqueueMicrotaskThe same program as an ES module prints a different order.
import { nextTick } from "node:process";
Promise.resolve().then(() => console.log("promise callback"));queueMicrotask(() => console.log("queueMicrotask"));nextTick(() => console.log("nextTick"));console.log("sync");syncpromise callbackqueueMicrotasknextTickIn 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:
import { readFile } from "node:fs";
readFile(import.meta.filename, () => { setTimeout(() => console.log("timeout"), 0); setImmediate(() => console.log("immediate")); console.log("callback done");});callback doneimmediatetimeoutBlocking 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.
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");loop finishedtimer ran, late: trueThe 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:
setImmediate(() => console.log("other task"));
function work(from) { console.log("chunk starting at", from); if (from < 4) { setImmediate(() => work(from + 2)); }}work(0);chunk starting at 0other taskchunk starting at 2chunk starting at 4The “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)againstsetImmediatein 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.nextTickordering. PreferqueueMicrotask.
Exercises
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.
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:
AHEGFBCDThe 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.
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 onefirstthirdShow solution
later returns a small closure that calls clearTimeout on the timer it created, so the caller never sees the timer object.
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");scheduled three, cancelled onefirstthirdThe 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.
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");work donetimer fired, work finished: trueThe timer waited until the work was over. Expected output after your change:
timer fired, work finished: falsework doneShow 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.
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);timer fired, work finished: falsework doneSummary
- 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). setTimeoutandsetIntervaltake a minimum delay, not an exact one. Cancel them withclearTimeoutandclearInterval.setImmediateruns after the I/O callbacks of the current turn.process.nextTickis 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.