Skip to content
Node.js fundamentals 3 min read

Async: Callbacks, Promises, async/await

Almost everything interesting in Node—reading files, querying databases, calling APIs—happens asynchronously. Node has evolved three patterns for managing async work: callbacks, Promises, and async/await. Modern code favors the last, but you’ll meet all three, so it pays to understand the progression.

Callbacks and callback hell

The original pattern passes a function to run when an operation finishes. Node uses the error-first convention: the first argument is an error (or null).

const fs = require("fs");

fs.readFile("a.txt", "utf8", (err, data) => {
  if (err) return console.error(err);
  console.log(data);
});

Callbacks work, but nesting them produces the infamous “pyramid of doom”:

getUser(id, (err, user) => {
  getOrders(user, (err, orders) => {
    getInvoice(orders[0], (err, invoice) => {
      // deeply nested, hard to read, error handling repeated everywhere
    });
  });
});

Callback hell isn’t just ugly—it scatters error handling and makes control flow nearly impossible to refactor.

Promises

A Promise represents a value that will exist eventually. It is pending, then either fulfilled or rejected. Chaining .then flattens the pyramid, and a single .catch handles all errors in the chain.

getUser(id)
  .then((user) => getOrders(user))
  .then((orders) => getInvoice(orders[0]))
  .then((invoice) => console.log(invoice))
  .catch((err) => console.error("Failed:", err.message));

You can wrap a callback API into a Promise, or use the built-in util.promisify / the fs/promises module.

const fs = require("fs/promises");
fs.readFile("a.txt", "utf8").then(console.log);

async / await

async/await is syntactic sugar over Promises that reads like synchronous code while staying non-blocking. An async function always returns a Promise; await pauses until a Promise settles.

async function run(id) {
  const user = await getUser(id);
  const orders = await getOrders(user);
  const invoice = await getInvoice(orders[0]);
  console.log(invoice);
}

Error handling with try/catch

With async/await, errors surface as exceptions, so ordinary try/catch works:

async function loadInvoice(id) {
  try {
    const user = await getUser(id);
    return await getInvoice(user);
  } catch (err) {
    console.error("Could not load invoice:", err.message);
    throw err; // re-throw if the caller should know
  } finally {
    console.log("done");
  }
}

Always await inside the try. A common bug is returning a Promise without awaiting it—the catch then never fires because the function already returned.

Combining Promises

When operations are independent, run them concurrently instead of sequentially.

MethodResolves whenRejects when
Promise.allAll fulfillAny one rejects (fails fast)
Promise.allSettledAll settleNever—returns status for each
Promise.raceFirst settlesIf the first to settle rejects
Promise.anyFirst fulfillsAll reject
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);

Use allSettled when you want every result regardless of individual failures:

const results = await Promise.allSettled([fetchA(), fetchB()]);
results.forEach((r) => {
  if (r.status === "fulfilled") console.log(r.value);
  else console.error(r.reason);
});

Output:

{ id: 1 }
Error: B is down

Common pitfalls

  • Sequential await in a loop when calls are independent—use Promise.all to parallelize.
  • Forgetting await, which leaks unhandled rejections and skips try/catch.
  • await inside forEach, which doesn’t wait; use for...of instead.
  • Mixing callbacks and Promises in one flow—standardize on async/await.

Best Practices

  • Default to async/await; reserve raw .then chains for short transformations.
  • Always handle rejections—attach .catch or wrap await in try/catch.
  • Parallelize independent work with Promise.all; use allSettled when partial failure is acceptable.
  • Prefer the fs/promises and other promise-based core APIs over callback variants.
  • Add a global process.on("unhandledRejection") handler as a safety net, not a substitute for local handling.
Last updated June 1, 2026
Was this helpful?