Callbacks, Promises & async/await

Node.js · Lesson 4 of 30 8 min read Updated

Almost everything useful a server does is slow compared with the processor: reading files, querying a database, calling another API. As you saw in the introduction, Node.js never waits for that work. It starts the job and moves on, and your code is told later when the result is ready. Code written this way is called asynchronous (async for short).

Over the years Node.js has used three styles for async code. Modern code uses async/await, but you will meet all three in real projects, so this lesson teaches each one, from oldest to newest, and shows how to convert old code to the new style.

Style 1: error-first callbacks (the original way)

In early Node.js, every async function took a callback as its last argument. When the job finished, Node.js called it with two arguments: an error first, then the result. This pattern is called the error-first callback.

JavaScript
import fs from "node:fs";

fs.readFile("students.txt", "utf8", (err, text) => {
  if (err) {
    console.error(`Could not read file: ${err.code}`);
    return;
  }
  console.log(`Students: ${text.trim()}`);
});

console.log("Reading the file...");
Output
Reading the file...
Students: Aarav,Priya,Kabir

The last line of code printed first, because readFile only starts reading. If the file did not exist, err would be an object with err.code set to "ENOENT" (short for "error, no entry") and text would be undefined. You must check err in every callback; if you forget, your code continues with missing data.

Callbacks work, but they get messy when one step depends on another. Reading a config file, then a student list, then saving a report gives you code that drifts to the right:

JavaScript
fs.readFile("config.json", "utf8", (err, config) => {
  if (err) return console.error(err);
  fs.readFile("students.txt", "utf8", (err, students) => {
    if (err) return console.error(err);
    fs.writeFile("report.txt", makeReport(config, students), (err) => {
      if (err) return console.error(err);
      console.log("Report saved");
    });
  });
});

Developers call this shape callback hell or the pyramid of doom. It is hard to read, and error handling is repeated at every level.

Style 2: promises

A promise is an object that stands for a result that is not ready yet. It is in one of three states: pending (still working), fulfilled (finished with a value) or rejected (failed with an error). Node.js offers promise versions of its modules, such as node:fs/promises.

JavaScript
import { readFile } from "node:fs/promises";

readFile("students.txt", "utf8")
  .then((text) => text.split(","))
  .then((names) => console.log(`Class size: ${names.length}`))
  .catch((err) => console.error(err.message))
  .finally(() => console.log("Finished"));
Output
Class size: 3
Finished

Each .then() receives the value from the step before it, so steps form a flat chain instead of a pyramid. One .catch() at the end handles an error from any step, and .finally() runs either way.

Style 3: async/await (the modern way)

async and await are built on promises but let you write async code that reads like normal top-to-bottom code. Inside an async function, await pauses that function until the promise settles, then gives you its value. The rest of the program keeps running, so the event loop is not blocked. Errors are handled with ordinary try...catch.

JavaScript
import { readFile } from "node:fs/promises";

async function showClassInfo() {
  try {
    const students = await readFile("students.txt", "utf8");
    const timetable = await readFile("timetable.txt", "utf8");
    console.log(`Students: ${students.trim()}`);
    console.log(`Timetable: ${timetable.trim()}`);
    await readFile("missing.txt", "utf8");
  } catch (err) {
    console.error(`Problem: ${err.code}`);
  }
}

await showClassInfo();
console.log("All done");
Output
Students: Aarav,Priya,Kabir
Timetable: Maths: Monday 10 am
Problem: ENOENT
All done

The last await showClassInfo() is at the top level of the file, outside any function. This top-level await works in ES modules, which is what this course uses. In CommonJS files (covered in ES Modules & CommonJS) you must put await inside an async function.

StyleError handlingWhere you see it
Error-first callbacksif (err) in every callbackOld code, some libraries, fs without /promises
Promises with .then().catch() at the end of the chainCode from around 2015 to 2018, short chains
async/awaittry...catchModern code; use it by default

Running tasks at the same time

await on two separate lines runs the tasks one after another. When the tasks do not depend on each other, start them together with Promise.all(), which waits for all of them:

JavaScript
import { setTimeout as sleep } from "node:timers/promises";

// Pretend each call asks a different server for a train's status.
async function getTrainStatus(train, ms) {
  await sleep(ms);
  return `${train}: on time`;
}

let start = Date.now();
const a = await getTrainStatus("Shatabdi", 300);
const b = await getTrainStatus("Rajdhani", 300);
console.log(a, "|", b);
console.log(`One after another: ${Date.now() - start} ms`);

start = Date.now();
const [c, d] = await Promise.all([
  getTrainStatus("Shatabdi", 300),
  getTrainStatus("Rajdhani", 300),
]);
console.log(c, "|", d);
console.log(`Together: ${Date.now() - start} ms`);
Output
Shatabdi: on time | Rajdhani: on time
One after another: 657 ms
Shatabdi: on time | Rajdhani: on time
Together: 322 ms

Running both together took about half the time. Your exact numbers will differ a little. Promise.all() rejects as soon as any one task fails. If you want every result, including failures, use Promise.allSettled():

JavaScript
const results = await Promise.allSettled([
  Promise.resolve("Jaipur: 31°C"),
  Promise.reject(new Error("Kochi weather service is down")),
  Promise.resolve("Pune: 27°C"),
]);

for (const r of results) {
  console.log(r.status === "fulfilled" ? r.value : `Failed: ${r.reason.message}`);
}
Output
Jaipur: 31°C
Failed: Kochi weather service is down
Pune: 27°C

Converting old callback functions

Most Node.js modules now have a promise version: node:fs/promises, node:timers/promises, node:dns/promises and node:stream/promises. For an old function that only takes an error-first callback, wrap it with util.promisify():

JavaScript
import { promisify } from "node:util";

// An old-style function that takes an error-first callback
function findSeat(coach, callback) {
  setTimeout(() => {
    if (coach === "S9") callback(new Error("Coach S9 does not exist"));
    else callback(null, `${coach}-42`);
  }, 50);
}

const findSeatAsync = promisify(findSeat);

console.log(await findSeatAsync("B2"));
try {
  await findSeatAsync("S9");
} catch (err) {
  console.log(`Error: ${err.message}`);
}
Output
B2-42
Error: Coach S9 does not exist

Unhandled rejections crash the program

If a promise rejects and nothing handles it, Node.js prints the error and stops the process with exit code 1. This is deliberate: an ignored error could leave your server in a broken state.

JavaScript
async function loadMarks() {
  throw new Error("Result server not reachable");
}

loadMarks(); // no await, no .catch()
console.log("This line still runs");
Output
This line still runs
C:\node-course\unhandled.js:2
  throw new Error("Result server not reachable");
        ^

Error: Result server not reachable
    at loadMarks (C:\node-course\unhandled.js:2:9)
    ...

Node.js v26.13.0

Advanced: what runs first?

When the main code finishes, Node.js first runs callbacks queued with process.nextTick(), then promise callbacks, then the event loop moves on to timers, I/O and setImmediate() callbacks.

JavaScript
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("main code");
Output
main code
nextTick
promise

You rarely need process.nextTick() in application code; it is mostly used inside libraries. One surprise: in the main file, setTimeout(fn, 0) and setImmediate(fn) can run in either order from one run to the next, so never write code that depends on their order.

Common mistakes

  • Forgetting await. const text = readFile("a.txt", "utf8") stores a promise, and console.log(text) prints Promise { <pending> }.
  • Using await inside forEach. forEach does not wait for async callbacks. Use for...of with await for one-by-one work, or Promise.all() with map() for parallel work.
  • Awaiting independent tasks one by one. Use Promise.all() when tasks do not depend on each other.
  • Mixing styles. Do not pass a callback to a promise function or await a callback function. Pick the promise version and use await.

Key points

  • Error-first callbacks were the original style: (err, result) => {}, and you must check err every time.
  • Promises represent a future result and chain with .then(), .catch() and .finally().
  • async/await is the modern style: write async code top to bottom and handle errors with try...catch.
  • Top-level await works in ES modules.
  • Use Promise.all() to run independent tasks together and Promise.allSettled() to collect every result.
  • util.promisify() turns an error-first callback function into a promise function.
  • Unhandled promise rejections stop the Node.js process.

Exercise

A file prices.txt contains 499,1299,250 on one line. Write total.js that uses node:fs/promises and async/await to read it, adds up the three prices and prints Total: ₹2048. If the file is missing, print prices.txt not found instead of crashing.

Show solution
JavaScript
// total.js
import { readFile } from "node:fs/promises";

try {
  const text = await readFile("prices.txt", "utf8");
  const total = text.trim().split(",").map(Number).reduce((sum, p) => sum + p, 0);
  console.log(`Total: ₹${total}`);
} catch (err) {
  if (err.code === "ENOENT") console.log("prices.txt not found");
  else throw err;
}
Output
Total: ₹2048

Top-level await keeps the code short. Checking err.code handles only the "file missing" case and rethrows anything unexpected, so real bugs are not hidden.

Quick Quiz

1. In an error-first callback, what is the first argument?

2. What does console.log(readFile("a.txt", "utf8")) print when you forget await?

3. Two API calls do not depend on each other. What is the fastest correct way to wait for both?

4. What happens on Node.js 26 when a promise rejects and nothing handles it?

Learn Node.js with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call