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/await over raw callbacks or long .then() chains — it reads top-to-bottom and works naturally with try/catch.
  • Always handle errors: try/catch around every await, .catch() on every promise chain, error-first checks in every callback, and "error" listeners on every emitter and stream.
  • Never use *Sync fs, 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) or Promise.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 async function readJsonConfig(path) that uses node:fs/promises to read and JSON.parse a 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), and fetchInventory(). 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/await are three syntaxes over the same underlying async machinery — modern code should prefer async/await with promise-based APIs like node:fs/promises.
  • Execution order is strict: synchronous code first, then the process.nextTick queue, then promise microtasks, then timers, I/O callbacks, and setImmediate, 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.allSettled to run independent async work concurrently instead of awaiting it sequentially.