Callbacks, Promises & async/await
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.
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...");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:
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.
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"));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.
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");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.
| Style | Error handling | Where you see it |
|---|---|---|
| Error-first callbacks | if (err) in every callback | Old code, some libraries, fs without /promises |
Promises with .then() | .catch() at the end of the chain | Code from around 2015 to 2018, short chains |
async/await | try...catch | Modern 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:
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`);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():
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}`);
}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():
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}`);
}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.
async function loadMarks() {
throw new Error("Result server not reachable");
}
loadMarks(); // no await, no .catch()
console.log("This line still runs");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.0Advanced: 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.
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("main code");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, andconsole.log(text)printsPromise { <pending> }. - Using
awaitinsideforEach.forEachdoes not wait for async callbacks. Usefor...ofwithawaitfor one-by-one work, orPromise.all()withmap()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
awaita callback function. Pick the promise version and useawait.
Key points
- Error-first callbacks were the original style:
(err, result) => {}, and you must checkerrevery time. - Promises represent a future result and chain with
.then(),.catch()and.finally(). async/awaitis the modern style: write async code top to bottom and handle errors withtry...catch.- Top-level
awaitworks in ES modules. - Use
Promise.all()to run independent tasks together andPromise.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
// 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;
}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?
Node.js callbacks receive the error first and the result second.
2. What does console.log(readFile("a.txt", "utf8")) print when you forget await?
Without await you get the promise object itself, not its value.
3. Two API calls do not depend on each other. What is the fastest correct way to wait for both?
Promise.all() starts both at once, so the total time is about the time of the slowest call.
4. What happens on Node.js 26 when a promise rejects and nothing handles it?
Since Node.js 15, unhandled rejections stop the process by default.
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
