Node.js Event Loop and Async Code: Callbacks, Promises, async/await
Web DevelopmentLesson 2 of 47 min read
What you'll learn
- Predict the order of sync code,
process.nextTick, promise callbacks, timers andsetImmediate - Show how one slow synchronous function stalls every other request
- Run independent async operations concurrently with
Promise.allandPromise.allSettled - Avoid the async mistakes that silently skip work or crash the process
On this page
Here is a script with seven console.log calls. The timer is set for 0 milliseconds, so surely it prints first among the deferred ones? It prints fifth. A promise that has already resolved jumps ahead of it, and a function called nextTick jumps ahead of the promise. Once you can explain that ordering, most of the confusing behaviour of asynchronous Node code stops being confusing.
One thread running your code, many things waiting
Your JavaScript runs on a single thread, one function at a time, never interrupted. That sounds like a limitation until you notice how little of a typical server's life is spent computing. Most of it is waiting: for a disk read, a database reply, a network packet. Node hands those waits to the operating system (and, for some operations like file reads and DNS, to a small pool of helper threads managed by libuv), and gets back to you when there is a result. The event loop is the mechanism that checks for finished work and runs the matching callback on your one thread.
Think of a reception desk at a clinic. One receptionist (your thread) can only talk to one person at a time. She never stands in the corridor watching a patient fill in a form. She hands over a clipboard, takes the next person, and when the form comes back it joins the queue. The desk is fast because she never waits, and it is slow the moment she decides to personally type up a long report while the queue grows.
The order things run in
The script below schedules work in five different ways. The numbers in each message are the order in which Node runs them, and I ran it five times to confirm the order is stable.
const { readFile } = require("node:fs");
console.log("1 sync: patient walks in");
setTimeout(() => console.log("5 timer: 0 ms reminder fires"), 0);
Promise.resolve().then(() => console.log("4 microtask: promise callback"));
process.nextTick(() => console.log("3 nextTick: queued before promises"));
readFile(__filename, () => {
// Inside an I/O callback the order of these two is fixed.
setTimeout(() => console.log("7 timer (scheduled inside I/O)"), 0);
setImmediate(() => console.log("6 immediate (scheduled inside I/O)"));
});
console.log("2 sync: end of the script");Output
1 sync: patient walks in 2 sync: end of the script 3 nextTick: queued before promises 4 microtask: promise callback 5 timer: 0 ms reminder fires 6 immediate (scheduled inside I/O) 7 timer (scheduled inside I/O)
The rules behind that output: first the whole script runs top to bottom (messages 1 and 2). Then, before the loop moves on, Node empties the `process.nextTick` queue and then the microtask queue (promise callbacks). After that the loop proceeds through its phases: expired timers, then poll (I/O callbacks), then the check phase where setImmediate callbacks run. Timers and immediates scheduled from the main script have no guaranteed relative order, so the example schedules the pair inside an I/O callback, where the immediate always wins. After every individual callback Node drains the nextTick and microtask queues again.
What happens when you block the loop
Because there is one thread, a long synchronous function stops everything: timers do not fire, sockets are not read, no other request is answered. The next program starts a heartbeat that should tick every 50 ms, then runs a 400 ms busy loop to stand in for something like building a large PDF or parsing a huge JSON string.
const { performance } = require("node:perf_hooks");
let ticks = 0;
let last = performance.now();
let worstGap = 0;
const heartbeat = setInterval(() => {
const now = performance.now();
worstGap = Math.max(worstGap, now - last);
last = now;
ticks++;
}, 50);
function spin(ms) {
const end = performance.now() + ms;
while (performance.now() < end) {} // stands in for a heavy report
}
setTimeout(() => {
const before = ticks;
spin(400);
console.log("heartbeats that fired during the 400 ms spin:", ticks - before);
setTimeout(() => {
clearInterval(heartbeat);
console.log("longest silence was over 400 ms:", worstGap > 400);
}, 100);
}, 220);Output
heartbeats that fired during the 400 ms spin: 0 longest silence was over 400 ms: true
Zero heartbeats fired during the spin, and the longest silence between ticks was more than the 400 ms we spent blocking. In a web server that gap is time during which every client waits. Typical fixes: move CPU-heavy work to a worker thread (node:worker_threads) or a separate process, break a big loop into slices that yield with setImmediate, or use a streaming API instead of one giant synchronous call. Synchronous file functions such as readFileSync are fine in a startup script and a bug inside a request handler.
Callbacks, promises and async/await are one mechanism
Older Node APIs take an error-first callback: fs.readFile(path, (err, data) => ...). Modern code uses promises: the node:fs/promises module, node:timers/promises and the global fetch all return them, and util.promisify converts older callback functions. async/await is syntax over promises: await pauses only the current async function, not the thread, and everything else keeps running while it waits. An async function always returns a promise, and a thrown error becomes a rejection.
That is why the next example matters. A reception desk needs three lookups for each patient: insurance, history, balance. They do not depend on each other, so waiting for them one at a time wastes two thirds of the time.
import { setTimeout as sleep } from "node:timers/promises";
// Each lookup stands in for a network call that takes 200 ms.
async function lookupInsurance(id) { await sleep(200); return { id, plan: "Gold" }; }
async function lookupHistory(id) { await sleep(200); return { id, visits: 7 }; }
async function lookupBalance(id) { await sleep(200); return { id, due: 35 }; }
const around = (ms) => `about ${Math.round(ms / 100) * 100} ms`;
let t = performance.now();
const a = await lookupInsurance(41);
const b = await lookupHistory(41);
const c = await lookupBalance(41);
console.log("one after another:", around(performance.now() - t));
t = performance.now();
const [ins, hist, bal] = await Promise.all([
lookupInsurance(41),
lookupHistory(41),
lookupBalance(41),
]);
console.log("all together: ", around(performance.now() - t));
console.log(ins.plan, hist.visits, bal.due);Output
one after another: about 600 ms all together: about 200 ms Gold 7 35
| Combinator | Resolves when | Rejects when | Use it for |
|---|---|---|---|
Promise.all | All fulfil (array of values) | Any rejects (first error wins) | Steps that are all required |
Promise.allSettled | All finish, never rejects | Never | Batches where partial success is fine |
Promise.race | First to settle | First to settle, if it rejected | Timeouts |
Promise.any | First to fulfil | All rejected (AggregateError) | Fastest of several mirrors |
import { setTimeout as sleep } from "node:timers/promises";
async function sendReminder(patient) {
await sleep(10);
if (patient.phone === null) throw new Error(`no phone number for ${patient.name}`);
return `sms sent to ${patient.name}`;
}
const queue = [
{ name: "Ira", phone: "555-0101" },
{ name: "Dev", phone: null },
{ name: "Mona", phone: "555-0103" },
];
const results = await Promise.allSettled(queue.map(sendReminder));
for (const r of results) {
console.log(r.status, "-", r.status === "fulfilled" ? r.value : r.reason.message);
}
try {
await Promise.all(queue.map(sendReminder));
} catch (err) {
console.log("Promise.all gave up with:", err.message);
}Output
fulfilled - sms sent to Ira rejected - no phone number for Dev fulfilled - sms sent to Mona Promise.all gave up with: no phone number for Dev
One bad record (Dev has no phone number) did not stop the other reminders under allSettled, but Promise.all gave up on the first failure. Note, though, that Promise.all does not cancel the other operations; they keep running, you just stop listening to them.
Common mistakes and what they look like
An async callback inside `forEach`. forEach ignores the promise your callback returns, so nothing waits for it. The first line of output below is printed before any booking happens, and anything after the loop runs too early. Use for...of with await for sequential work, or Promise.all(slots.map(...)) for parallel work.
import { setTimeout as sleep } from "node:timers/promises";
const slots = ["09:00", "09:30", "10:00"];
async function book(slot) {
await sleep(20);
console.log("booked", slot);
}
slots.forEach(async (slot) => {
await book(slot);
});
console.log("forEach finished -- or did it?");
await sleep(100);
console.log("--- now with for...of");
for (const slot of slots) {
await book(slot);
}
console.log("really finished");Output
forEach finished -- or did it? booked 09:00 booked 09:30 booked 10:00 --- now with for...of booked 09:00 booked 09:30 booked 10:00 really finished
A promise nobody handles. If a rejected promise has no catch and is not awaited inside a try, modern Node treats it as a fatal error: it prints the stack and exits with code 1. Locally you see the process die; in production you see a container restart loop.
async function loadQueue() {
throw new Error("queue service unreachable");
}
loadQueue(); // nobody awaits it, nobody catches it
console.log("carrying on...");Output
carrying on...
file:///.../unhandled.mjs:2
throw new Error("queue service unreachable");
^
Error: queue service unreachable
at loadQueue (file:///.../unhandled.mjs:2:9)
...
Node.js v22.15.0
(exit code 1)- Forgetting `await`. Symptom: you print
Promise { <pending> }instead of your data, or a later line seesundefinedproperties. - Awaiting inside a loop when the calls are independent. Symptom: no error, just a response that takes three times as long as it should.
- Heavy CPU work in a handler. Symptom: unrelated endpoints become slow whenever one particular endpoint is called.
How this shows up in real projects and interviews
Interviewers love the predict-the-output question: a few console.log, a setTimeout(..., 0), a promise and a nextTick, and you explain the order using the queues above. The follow-up is usually "Node is single threaded, so how does it handle 10,000 connections?", which you now answer by saying that waiting is delegated to the OS while the single thread only runs short callbacks. On real teams, the same knowledge helps you find why a service's latency spikes (something is blocking the loop), why a job silently stopped halfway (an unawaited promise), and when to reach for a worker thread or a queue. Next you will see the loop meet real I/O: Node.js File System, Streams and Buffers.
Try it yourself
Write a script that simulates fetching prices for five products with an async function that waits 150 ms and rejects for exactly one product. First run the five calls one after another and print the total time rounded to the nearest 100 ms. Then run them with Promise.allSettled, print how many succeeded and failed, and print the rounded time again. Finally, add a setInterval heartbeat and a 300 ms synchronous loop, and observe what happens to the heartbeat.
Show a hint
Use import { setTimeout as sleep } from "node:timers/promises" for the wait, and performance.now() (a global) to measure. allSettled returns objects with status: "fulfilled" or "rejected", so filter on that field to count.
Key takeaways
- JavaScript runs on one thread; waiting on I/O is delegated to the OS and helper threads, and the event loop runs callbacks as results arrive.
- After each callback Node drains
process.nextTickfirst, then promise microtasks, before returning to timers, poll and check phases. - Never block the loop with long synchronous work; offload it to worker threads or another process, or slice it.
- Independent awaits belong in
Promise.all(all required) orPromise.allSettled(partial success is acceptable). forEachignores returned promises and unhandled rejections crash modern Node, so usefor...oforPromise.alland always handle errors.
Test yourself
A trick from this lesson's ride: The receptionist hands out clipboards; she never stands watching someone fill one in. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
How does the Node.js event loop work?
Node runs your JavaScript on one thread. When your code starts an I/O operation or timer, the work is handed to the operating system or a helper thread. The event loop repeatedly checks for finished work and runs the matching callback, cycling through timers, I/O polling and the check phase for setImmediate, and draining process.nextTick and promise queues after each callback.
Is Node.js single threaded?
Your JavaScript code runs on a single main thread, but Node itself is not purely single threaded. libuv keeps a pool of worker threads for operations like file system calls and DNS, the OS handles network I/O, and you can start your own threads with worker_threads. The practical rule is that no two pieces of your JavaScript run at the same instant on the main thread.
What is the difference between process.nextTick and setImmediate?
process.nextTick callbacks run as soon as the current operation finishes, before the event loop continues and before promise callbacks in CommonJS code. setImmediate callbacks wait for the check phase of the loop, after pending I/O. Overusing nextTick can starve I/O because the loop cannot move on until its queue is empty.