Skip to main content
CodeOath
← All posts

Node.js95 min total · 14 parts

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

Part 7 of 14 · ~4 min

Async Patterns in Practice

Meridian's own Node SDK evolved across the three eras of Node's async story, and so, in step, did the wrapper billhook writes around it. Same operation the whole way through — look up an order, then fetch its latest status from Meridian — three ways.

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

function loadOrderAndStatus(orderId, callback) {
  getOrder(orderId, (err, order) => {
    if (err) return callback(err);
    getMeridianStatus(order.meridianId, (err, status) => {
      if (err) return callback(err);
      callback(null, { order, status });
    });
  });
}

The convention is baked in everywhere: the first argument to any Node-style callback is always the error, checked before anything else gets touched. Stack three or four of these inside each other and the result earns its nickname — callback hell — indentation drifting rightward with every level, the identical error check retyped at each one, and no single try/catch that could wrap the whole sequence even if you wanted it to.

Promises (the same two calls, chained):

function loadOrderAndStatus(orderId) {
  return getOrderAsync(orderId)
    .then((order) => getMeridianStatusAsync(order.meridianId).then((status) => ({ order, status })))
    .catch((err) => { throw err; }); // rethrow, or handle here
}

Chaining flattens the nesting and centralizes the error handling into one .catch(), but notice what that inner .then() is actually there for — purely so order is still reachable by the time the second call needs it. That specific awkwardness is exactly what async/await exists to remove.

async/await (the same mechanism, read top to bottom):

async function loadOrderAndStatus(orderId) {
  const order = await getOrderAsync(orderId);
  const status = await getMeridianStatusAsync(order.meridianId);
  return { order, status };
}

Same Promises underneath, but it reads like synchronous code, and one try/catch around the whole thing catches either call's rejection identically to a thrown error.

Pitfall: the confirmation email nobody reported failing

async function notifyOps(order) {
  throw new Error("ops-notify channel unreachable");
}

async function handleMeridianWebhook(payload) {
  await saveOrder(payload);
  notifyOps(payload.order); // no await, no .catch() — this rejection has nowhere to go
  return { received: true };
}

Skip both the await and any .catch() on an async call, and whatever that call eventually rejects with has genuinely nowhere to go. Node hasn't treated that as a footnote for a long time now — left alone, it takes the entire process down, the same outcome you'd get from an exception nobody caught anywhere in synchronous code. billhook runs a global safety net so this shows up somewhere instead of vanishing silently:

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

Common mistake: calling an async function and walking away from it — no await, no .catch() — on the assumption that whatever goes wrong inside it will announce itself eventually. Nothing announces it. What actually happens splits on two things you may not be thinking about in the moment: which Node version is running, and whether anyone bothered to wire up a global rejection handler. Get it wrong on both counts and the failure is simply gone.

The eleven-second freeze, explained

This is the payoff for chapter one. Here's the line that was actually in billhook's first version, and it's not I/O at all:

app.post("/webhooks/meridian", express.raw({ type: "application/json" }), (req, res) => {
  const payload = JSON.parse(req.body);
  const receiptHash = computeReceiptFingerprintSync(payload.receiptBytes); // pure synchronous CPU work
  saveOrder(payload, receiptHash);
  res.json({ received: true });
});

computeReceiptFingerprintSync hashes the attached receipt image against a dedup index, entirely synchronously, and on a large batch of retried receipts after a brief Meridian outage, that took a little over eleven seconds of raw CPU time. Recall chapter one: there's exactly one JS thread. For those eleven seconds, every other request billhook was holding — deliveries for completely unrelated customers, health checks, everything — waited those same eleven seconds, because nothing else gets a turn while the call stack still has something on it, and this particular hash refused to leave the stack until it was completely done. Nothing crashed, nothing errored, because nothing was actually broken in the sense a stack trace could describe — the service was simply doing exactly one thing, on its one thread, for eleven seconds, and everything else queued behind it. It's a trap that's specifically shaped for Node, more than for a server built on one thread or process per request — Node is so effective at making I/O latency invisible that the CPU-bound exception to that rule is easy to lose track of entirely. Two real fixes exist: break the work into pieces small enough to hand control back to the loop in between, or, when the computation is genuinely this expensive, ship it off to a worker_threads worker running on its own actual thread, leaving the main one completely free to keep answering everything else billhook is holding.