The node async await flow looks confusing at first: callbacks, Promises and async/await all seem to do the same job in different ways. In reality they are three generations of the same idea. Node.js runs on a single thread, and operations like reading a file, querying a database or making an HTTP request take time. In this guide you'll learn why asynchronous flow is necessary, how we moved from callback hell to Promises and then to the clean syntax of async/await, how to handle errors, and how to run work in parallel.
Why asynchronous? A quick look at the event loop
Node.js runs on a single main thread and is built around the principle of never blocking that thread. Reading a file from disk takes milliseconds; if Node simply waited that whole time, it couldn't process any other incoming request meanwhile. Instead it hands the operation off to the operating system, moves on to other work, and comes back when the result is ready. This mechanism is called the event loop.
An important distinction: asynchronous code does not mean threads running in parallel. There is a single thread; only the waiting periods are managed cleverly. That's why a long CPU computation (say a huge loop) still blocks the event loop — async/await doesn't help there; it is designed only for operations that wait on I/O.
First generation: callbacks and "callback hell"
In Node's early days, asynchronous operations were managed with callback functions. The result of an operation comes back through a function you pass as an argument. Node's convention is the "error-first callback": the first parameter is the error, the second is the result.
const fs = require("fs");
fs.readFile("a.txt", "utf8", (err, data) => {
if (err) return console.error(err);
console.log(data);
});
For a single operation that's fine. But once operations depend on each other, the code turns into a pyramid that drifts to the right. This is called callback hell:
readFile("a.txt", (err, a) => {
readFile("b.txt", (err, b) => {
readFile("c.txt", (err, c) => {
// error checking repeats at every level
});
});
});
This structure is hard to read, hard to handle errors in, and hard to maintain. Promises arrived to solve exactly this problem.
Second generation: what is a Promise?
A Promise is an object that represents "a value that isn't ready yet but will arrive in the future". It has three states: pending, fulfilled (completed successfully) and rejected (ended with an error). You catch the result with .then() and the error with .catch():
const fs = require("fs/promises");
fs.readFile("a.txt", "utf8")
.then((data) => console.log(data))
.catch((err) => console.error(err));
The real power of Promises is chaining. Each .then() returns a new Promise, so instead of a pyramid you get a flat flow. Even so, readability drops in very long chains — and that's where async/await comes in.
Third generation: synchronous-looking code with node async await
async/await is syntactic sugar built on top of Promises; underneath, Promises are still running. If you mark a function with async, you can use await inside it. await "waits" on that line until a Promise resolves, but it does not block the event loop.
const fs = require("fs/promises");
async function read() {
const a = await fs.readFile("a.txt", "utf8");
const b = await fs.readFile("b.txt", "utf8");
return a + b;
}
This code does the same job as the callback pyramid but reads top to bottom, like synchronous code. Three rules to keep in mind:
awaitcan only be used inside anasyncfunction (or, in modern Node, at the top level of modules).- An
asyncfunction always returns a Promise; whatever youreturninside, the caller receives it viaawaitor.then(). awaitdoesn't only speed up I/O; CPU-heavy work still blocks the main thread.
Error handling: clean catching with try/catch
With callbacks you had to write if (err) at every level. With async/await you use try/catch just like in ordinary, synchronous code. This is one of async/await's biggest practical wins:
async function read() {
try {
const data = await fs.readFile("missing.txt", "utf8");
return data;
} catch (err) {
console.error("Read failed:", err.message);
return null;
}
}
If an await expression is rejected, the catch block fires. If you don't catch the error, it becomes an unhandled rejection, which in modern Node versions can crash the process. So every asynchronous operation needs an error strategy: either a local try/catch, or a Promise rejection caught further up by the caller.
Running in parallel: Promise.all and allSettled
A common mistake is to wait for independent operations one after another for no reason. The code below reads two files sequentially; the second waits for the first to finish:
const a = await fs.readFile("a.txt", "utf8"); // this finishes first
const b = await fs.readFile("b.txt", "utf8"); // then this starts
If the operations don't depend on each other, starting both at the same time and awaiting them together is much faster. For that you use Promise.all:
const [a, b] = await Promise.all([
fs.readFile("a.txt", "utf8"),
fs.readFile("b.txt", "utf8"),
]);
Promise.all rejects as a whole if even one of its operations rejects. If you want to allow some operations to fail and still see every result, use Promise.allSettled; it returns the status (fulfilled/rejected) of each operation separately.
Common mistakes
awaitwithforEachin a loop.array.forEach(async ...)doesn't work the way you expect;forEachdoes not wait for Promises. Use a classicfor...ofto wait sequentially, orPromise.all(array.map(...))to run in parallel.- Forgetting
await. Withoutawait, the variable holds an unresolved Promise object, not the value. - Needless sequential waiting. Awaiting independent operations one by one slows your requests for no reason.
Frequently Asked Questions
Should I use async/await or .then()?
Both use the same Promise foundation; the choice is about readability. In flows with many dependent steps, async/await usually reads cleaner. For a single short transformation, .then() can be practical. Staying consistent within the same codebase is best.
Does await slow down code?
await by itself doesn't slow code down; it merely defers the function's progress until that Promise resolves, and the event loop keeps doing other work meanwhile. Slowness usually comes from awaiting independent operations sequentially for no reason — parallelize them with Promise.all.
Is async/await enough for CPU-heavy work?
No. async/await only manages I/O waits efficiently. A heavy computation (encryption, image processing) still blocks the main thread. For that you should consider the worker_threads module or a separate process.
Want to fix the asynchronous flow in your Node.js project? To clean up a codebase that turned into callback hell with async/await, boost performance through parallelism, or build a solid API from scratch, get in touch with me.