CodeOath
← All posts
Node.js36 min total · 14 parts

Node.js Fundamentals: The Runtime, the Event Loop, and Building Real APIs

Part 7 of 14 · ~3 min

Async Patterns in Practice

Node's async story evolved in three stages, and you'll still find all three in real codebases. Here's the exact same operation — fetch a user record, then fetch their orders — written all three ways.

Callbacks (the original pattern, Node's "error-first" convention):

function loadUserAndOrders(userId, callback) {
  getUser(userId, (err, user) => {
    if (err) return callback(err);
    getOrders(user.id, (err, orders) => {
      if (err) return callback(err);
      callback(null, { user, orders });
    });
  });
}

Every Node-style callback takes (err, result) as its first two arguments — check err first, always. Nest more than two or three of these and you get "callback hell": indentation creeping right, error handling repeated at every level, and no way to try/catch around the whole thing.

Promises (wrapping the same operations):

function loadUserAndOrders(userId) {
  return getUserAsync(userId)
    .then((user) => getOrdersAsync(user.id).then((orders) => ({ user, orders })))
    .catch((err) => { throw err; }); // rethrow, or handle here
}

Chaining flattens the nesting somewhat and centralizes error handling in one .catch(), but the nested .then() needed just to keep user in scope for the second call is awkward — a pattern async/await removes entirely.

async/await (syntax sugar over the same Promises):

async function loadUserAndOrders(userId) {
  const user = await getUserAsync(userId);
  const orders = await getOrdersAsync(user.id);
  return { user, orders };
}

Same underlying mechanism, but it reads top-to-bottom like synchronous code, and a plain try/catch around it handles both calls' rejections identically to how it'd catch a thrown error.

Pitfall: unhandled promise rejections

async function riskyOperation() {
  throw new Error("something went wrong");
}

riskyOperation(); // no await, no .catch() — the rejection has nowhere to go

Calling an async function without await-ing it or attaching a .catch() leaves its returned Promise's rejection completely unhandled. In modern Node, an unhandled rejection isn't just a silent warning — by default it crashes the process, the same as an uncaught synchronous exception. You can observe (and log) these globally as a safety net, but relying on that instead of handling errors at the call site just delays where the bug shows up:

process.on("unhandledRejection", (reason) => {
  console.error("Unhandled rejection:", reason);
  // log it, alert on it — but fix the missing .catch()/await at the source too
});

Common mistake: firing off an async call inside a synchronous function or an event handler ("fire and forget") without a .catch(), assuming errors will somehow surface on their own. They won't — they'll either vanish or crash the process, depending on Node version and whether a global handler is registered.

Pitfall: blocking the event loop with synchronous CPU work

app.get("/report", (req, res) => {
  const result = computeExpensiveReportSync(hugeDataset); // pure synchronous CPU work
  res.json(result);
});

Remember from the runtime section: there's exactly one JS thread. If computeExpensiveReportSync takes 3 seconds of raw CPU time, every other request the process is handling — including ones that don't touch this route at all — waits those same 3 seconds, because the event loop can't service any other callback until the call stack is empty again. This is a much easier mistake to make in Node than in a multi-process or multi-threaded server, precisely because Node's concurrency model is so good at hiding I/O latency that it's easy to forget CPU-bound work doesn't get the same treatment. The fix is either breaking the work into asynchronous chunks (yielding back to the event loop between pieces), or — for genuinely heavy computation — offloading it to a worker_threads worker so it runs on a separate thread entirely, leaving the main thread free to keep serving other requests.