Asynchronous Programming in Node.js
Node.js runs your JavaScript on a single thread, yet it can juggle thousands of file reads, database queries, and network requests at once without ever freezing up. The trick is non-blocking, asynchronous I/O: instead of waiting for a slow operation to finish before moving on, Node hands the work off and keeps executing other code, then runs your callback, promise handler, or awaited continuation once the result is ready. Understanding exactly when that continuation runs — and in what order — is the single most important skill for writing correct, fast Node.js programs.
Overview: How Asynchronous Code Works in Node.js
JavaScript itself is single-threaded: one call stack, one thing executing at a time. Node.js adds a runtime built on libuv, a C library that provides the event loop and a small thread pool (4 threads by default). When you call an async API, Node does not block the thread waiting for it. Instead:
- For most file system, DNS lookup, and some crypto operations, libuv hands the work to a thread in the thread pool. When that thread finishes, it queues your callback to run on the main thread.
- For network I/O (TCP sockets, HTTP requests), the operating system itself provides async notification (epoll on Linux, kqueue on macOS, IOCP on Windows), so no thread pool thread is needed at all — the OS tells libuv when data is ready.
- Either way, your JavaScript callback always runs back on the single main thread, one at a time, never in parallel with other JavaScript.
Node.js gives you three syntactic ways to work with this async machinery, all built on the same underlying event loop:
- Callbacks — a function passed as an argument, invoked later with the result (the original Node.js style, still used by some core APIs).
- Promises — objects representing a future value, with
.then()/.catch(). - async/await — syntax sugar over promises that lets asynchronous code read like synchronous code.
Modern Node.js code should default to promises and async/await. Most core modules now ship a promise-based version alongside the classic callback version — for example node:fs/promises next to node:fs.
Syntax: Callbacks, Promises, and Async/Await
The three styles wrap the same underlying operation differently:
| Style | General form | Error handling |
|---|---|---|
| Callback | fn(args, (err, result) => { ... }) |
Check err as the first parameter (“error-first” convention) |
| Promise | fn(args).then(result => { ... }).catch(err => { ... }) |
.catch() handles rejection |
| Async/await | const result = await fn(args); inside an async function |
try { ... } catch (err) { ... } |
A function can only be awaited if it returns a promise. Callback-based APIs do not return promises, which is why Node.js ships util.promisify() to convert them, and why many core modules ship a separate */promises entry point instead.
Examples
Example 1: The classic callback style
This uses the original node:fs callback API. Assume config.json in the same folder contains {"port": 3000}.
const fs = require("node:fs");
fs.readFile("./config.json", "utf8", (err, data) => {
if (err) {
console.error("Failed to read config:", err.message);
return;
}
const config = JSON.parse(data);
console.log("Loaded config:", config);
});
console.log("Reading config file...");
Output:
Reading config file...
Loaded config: { port: 3000 }
Notice the order: "Reading config file..." prints before "Loaded config", even though readFile was called first. fs.readFile returns immediately, the callback is queued for later, and the synchronous code after it (the second console.log) runs right away.
Example 2: Promises with async/await and concurrency
This uses node:fs/promises and runs two independent reads concurrently with Promise.all instead of awaiting them one after another. Assume app.json contains {"port": 3000} and db.json contains {"host": "localhost"}.
const fs = require("node:fs/promises");
async function loadConfigs() {
try {
const [appConfig, dbConfig] = await Promise.all([
fs.readFile("./app.json", "utf8"),
fs.readFile("./db.json", "utf8"),
]);
console.log("App config:", JSON.parse(appConfig));
console.log("DB config:", JSON.parse(dbConfig));
} catch (err) {
console.error("Failed to load configs:", err.message);
}
}
loadConfigs();
Output:
App config: { port: 3000 }
DB config: { host: 'localhost' }
Because both files are independent, Promise.all starts both reads at the same time and waits for both to finish, instead of paying for two reads back-to-back with await fs.readFile(...) twice in sequence.
Example 3: Seeing the execution order for yourself
This program schedules work on every queue Node.js has, so you can see the real firing order.
const fs = require("node:fs");
console.log("start");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
console.log("end");
Output:
start
end
nextTick
promise
immediate
timeout
All synchronous code ("start", "end") runs first. Then the process.nextTick queue drains completely, then the promise microtask queue. Only after all of that does the event loop move on to actual I/O and timers — and because setImmediate and setTimeout were scheduled from inside an I/O callback (the poll phase), setImmediate is guaranteed to fire before the timer.
Under the Hood: The Event Loop and libuv
On every turn, the event loop moves through fixed phases, each with its own callback queue:
| Phase | What runs there |
|---|---|
| Timers | setTimeout / setInterval callbacks whose delay has elapsed |
| Pending callbacks | Certain system-level callbacks deferred from the previous loop iteration |
| Poll | Fetches new I/O events; runs I/O callbacks (like fs.readFile‘s callback) |
| Check | setImmediate callbacks |
| Close callbacks | close event handlers, e.g. socket.on("close", ...) |
Two queues sit outside these phases and are drained between every single callback, not just between phases: the nextTick queue (process.nextTick) and the microtask queue (resolved promises). process.nextTick always runs before promise microtasks. This is why, in Example 3, "nextTick" printed before "promise", and both printed long before any timer or I/O callback.
The practical rule that falls out of all this: if you block the main thread with synchronous, CPU-heavy code, nothing else can run — not other requests, not timers, not I/O callbacks that are ready and waiting. There is only one thread running your JavaScript, no matter how many async operations are in flight.
Common Mistakes
Mistake 1: Blocking the event loop with a synchronous call
Using a *Sync function inside a request handler stalls every other connection the server is handling, because the whole process is waiting on disk I/O on the one thread that matters.
const fs = require("node:fs");
const http = require("node:http");
const server = http.createServer((req, res) => {
// Blocks the entire event loop until the file is fully read from disk —
// every other request has to wait, no matter how small.
const data = fs.readFileSync("./big-report.csv", "utf8");
res.writeHead(200, { "Content-Type": "text/csv" });
res.end(data);
});
server.listen(3000);
Fix it by using the promise-based API so the read happens off the main thread’s execution while other requests keep being served:
const fs = require("node:fs/promises");
const http = require("node:http");
const server = http.createServer(async (req, res) => {
try {
const data = await fs.readFile("./big-report.csv", "utf8");
res.writeHead(200, { "Content-Type": "text/csv" });
res.end(data);
} catch (err) {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("Internal Server Error");
}
});
server.listen(3000);
Mistake 2: Unhandled promise rejections
Calling an async function without a try/catch or a trailing .catch() means a rejection has nowhere to go.
async function loadUser(id) {
const user = await db.findUser(id);
return user;
}
// No try/catch here and no .catch() on the call — if findUser() rejects,
// Node prints an "UnhandledPromiseRejection" warning and the app can crash.
loadUser(42);
Always attach a handler, even if it just logs the error:
async function loadUser(id) {
const user = await db.findUser(id);
return user;
}
loadUser(42).catch((err) => {
console.error("Failed to load user:", err.message);
});
Mistake 3: Forgetting the “error” event
EventEmitter-based objects (including every stream) treat an "error" event with no listener as fatal — Node throws and the process exits.
const { EventEmitter } = require("node:events");
const emitter = new EventEmitter();
// Emitting "error" with no listener attached throws and crashes the process.
emitter.emit("error", new Error("boom"));
Always attach an "error" listener on emitters and streams you control:
const { EventEmitter } = require("node:events");
const emitter = new EventEmitter();
emitter.on("error", (err) => {
console.error("Emitter error:", err.message);
});
emitter.emit("error", new Error("boom"));
Output:
Emitter error: boom
Best Practices
- Default to
async/awaitover raw callbacks or long.then()chains — it reads top-to-bottom and works naturally withtry/catch. - Always handle errors:
try/catcharound everyawait,.catch()on every promise chain, error-first checks in every callback, and"error"listeners on every emitter and stream. - Never use
*Syncfs, crypto, or zlib functions inside request handlers or other hot paths — reserve them for one-off CLI startup code that runs before the server starts accepting connections. - Run independent async operations concurrently with
Promise.all(fail-fast) orPromise.allSettled(collect all results/errors) instead of awaiting them one at a time. - Use
util.promisify()to convert legacy callback-based APIs into promises rather than hand-rolling new callback code. - Don’t mix callback and promise styles for the same operation in the same function — pick one API surface and stick with it.
- Make sure every code path in an HTTP handler calls
res.end()exactly once, including error paths. - Use
process.on("unhandledRejection", ...)as a development-time safety net for logging, but treat it as a signal to add the missing.catch(), not a permanent fix.
Practice Exercises
- Write an
asyncfunctionreadJsonConfig(path)that usesnode:fs/promisesto read andJSON.parsea file, returning a friendly error message (instead of crashing) if the file does not exist. - You have three independent async functions:
fetchUser(id),fetchOrders(id), andfetchInventory(). Rewrite code that currently awaits them one after another so all three run concurrently, then combine their results into one object. - Without running it, predict the console output order of a script that calls, in this sequence:
console.log("A"),setTimeout(() => console.log("B"), 0),process.nextTick(() => console.log("C")),Promise.resolve().then(() => console.log("D")),console.log("E"). Then run it in Node.js to check your answer.
Summary
- Node.js is single-threaded for your JavaScript, but achieves concurrency through non-blocking I/O powered by libuv’s event loop and thread pool.
- Callbacks, promises, and
async/awaitare three syntaxes over the same underlying async machinery — modern code should preferasync/awaitwith promise-based APIs likenode:fs/promises. - Execution order is strict: synchronous code first, then the
process.nextTickqueue, then promise microtasks, then timers, I/O callbacks, andsetImmediate, in that phase order. - Synchronous (
*Sync) calls block the entire event loop — never use them in request handlers or other code paths shared by many operations. - Every async operation needs error handling:
try/catch,.catch(), error-first checks, or an"error"listener — skipping it causes silent failures or crashed processes. - Use
Promise.all/Promise.allSettledto run independent async work concurrently instead of awaiting it sequentially.
