Skip to main content
Madhukar
All Articles

Async Code in Node.js: Callbacks and Promises

July 28, 20265 min read
Node.jsAsyncPromisesBackend
Async Code in Node.js: Callbacks and Promises

Reading a file shouldn’t freeze your whole server

Imagine a Node.js server that needs to read a file from disk. Disk operations aren’t instant — they take real time, even if it’s just milliseconds. If Node.js waited, doing absolutely nothing else, until that file finished reading, every other request hitting the server at that exact moment would simply have to wait in line behind it.

That’s not how Node.js actually behaves — and understanding why is the key to understanding asynchronous code. This guide walks through why async code exists, how callbacks handle it, where callbacks start to break down, and how promises fixed the exact problem callbacks introduced.

Why Async Code Exists in Node.js

Node.js runs JavaScript on a single thread — meaning, at its core, it can only actively execute one piece of code at a time. If reading a file blocked that single thread until the disk finished responding, the entire server would sit frozen, unable to handle any other request, for however long that read took.

Asynchronous code solves this by starting a slow operation (reading a file, querying a database, making a network request) and immediately moving on to other work, rather than sitting idle and waiting. When the slow operation eventually finishes, Node.js circles back and runs the code that was waiting for that result.

This is precisely why Node.js can comfortably handle thousands of simultaneous connections despite being single-threaded — it’s rarely actually waiting on anything; it’s constantly moving on to the next available piece of work.

Callback-Based Async Execution

The original, foundational way Node.js handles this is the callback — a function passed as an argument, which gets called once the asynchronous operation actually completes.

const fs = require("fs");

fs.readFile("data.txt", "utf8", (err, data) => {
if (err) {
console.log("Something went wrong:", err.message);
return;
}
console.log("File contents:", data);
});

console.log("This runs immediately, before the file is done reading");

Walking through the flow, step by step

  1. fs.readFile is called, and immediately returns — it doesn't wait for the file to finish reading
  2. Node.js starts the actual file-reading operation in the background
  3. The line console.log("This runs immediately...") runs right away, since Node.js didn't pause for the read
  4. Once the file finishes reading, Node.js calls the callback function — now, err and data are available, and the code inside the callback finally runs

The callback is essentially a note that says: “when you’re done with this, come back and run this specific piece of code” — letting the rest of the program keep moving in the meantime.

Problems With Nested Callbacks

Real applications rarely need just one asynchronous step — they need several, often depending on each other’s results. With callbacks, that naturally means nesting one callback inside another:

fs.readFile("user.json", "utf8", (err, userData) => {
if (err) return console.log(err);

fs.readFile("preferences.json", "utf8", (err, prefsData) => {
if (err) return console.log(err);

fs.readFile("history.json", "utf8", (err, historyData) => {
if (err) return console.log(err);

console.log("All data loaded:", userData, prefsData, historyData);
});
});
});

This pattern has a well-known nickname: callback hell (or the “pyramid of doom”) — named for the way each new asynchronous step indents further and further to the right.

Why this becomes a real problem

  • Readability collapses — following the actual sequence of steps means constantly tracking which closing brace belongs to which callback, several levels deep
  • Error handling gets repetitive and easy to miss — every single nested callback needs its own if (err) check, and it's easy to forget one, silently swallowing an error somewhere in the middle
  • Refactoring becomes risky — reordering or removing a step means carefully untangling nested nested functions, not just moving a line up or down
  • Shared variables become awkward — data from an outer callback has to stay in scope for every nested callback beneath it, which can get unwieldy fast

This exact pain point — not asynchronous code itself, but the specific shape callbacks force it into — is what promises were built to solve.

Promise-Based Async Handling

A Promise represents a value that isn’t available yet, but will be at some point — either successfully (resolved) or unsuccessfully (rejected). Instead of nesting callbacks inside callbacks, promises let you chain steps in a flat, linear sequence using .then().

const fs = require("fs/promises");

fs.readFile("user.json", "utf8")
.then(userData => {
console.log("User data:", userData);
return fs.readFile("preferences.json", "utf8");
})
.then(prefsData => {
console.log("Preferences:", prefsData);
return fs.readFile("history.json", "utf8");
})
.then(historyData => {
console.log("History:", historyData);
})
.catch(err => {
console.log("Something went wrong:", err.message);
});

The promise lifecycle

Every promise moves through one of these states:

  • Pending — the operation hasn’t finished yet
  • Fulfilled — the operation completed successfully, and .then() runs with the result
  • Rejected — the operation failed, and .catch() runs with the error

Comparing readability directly

// Callbacks: nested, indentation grows with every step
readFile(a, () => {
readFile(b, () => {
readFile(c, () => { /* ... */ });
});
});

// Promises: flat, each step reads top to bottom
readFile(a)
.then(() => readFile(b))
.then(() => readFile(c))
.catch(handleError);

The promise version reads in the exact order the steps actually happen — no indentation creep, and a single .catch() at the end handles errors from any step in the chain, instead of repeating an error check after every individual callback.

Benefits of Promises

Flatter, more readable code

Each step in a promise chain sits at the same indentation level, mirroring the actual order operations happen in — a direct fix for the pyramid-of-doom problem nested callbacks create.

Centralized error handling

A single .catch() at the end of a chain catches an error from any preceding step, removing the need to repeat error-checking logic after every individual asynchronous call.

Composability

Promises can be combined — Promise.all() runs several promises concurrently and waits for all of them; Promise.race() resolves as soon as the first one finishes — patterns that are considerably messier to express with raw nested callbacks.

A foundation for async/await

Modern async/await syntax (a further readability improvement, worth its own dedicated article) is built directly on top of promises — understanding promises first is what makes async/await make real sense, rather than feeling like unexplained magic syntax.

Final Takeaway

Asynchronous code exists in Node.js for a genuinely good reason: a single-threaded server that never had to wait for slow operations would be nearly useless at real scale. Callbacks were the original, foundational way to say “run this once that’s done” — but nesting them for multi-step operations produces exactly the tangled, hard-to-maintain shape known as callback hell. Promises solve that specific shape problem: the same asynchronous behavior, expressed as a flat, readable chain, with error handling centralized in one place instead of scattered through every nested level. Neither approach changes what Node.js is actually doing under the hood — they’re two different ways of writing the same underlying idea, one of them considerably easier to read and maintain as real applications grow.

Frequently Asked Questions

Are callbacks obsolete now that promises exist?

> Not entirely — many core Node.js APIs still support callback-based versions for compatibility, and callbacks remain a fundamental building block promises are built on top of. In new code, though, promises (or async/await) are generally preferred for anything beyond a single, simple asynchronous step.

Does using promises make code run faster?

> No — promises don’t change the underlying speed of an operation; they change how the code describing that operation is structured and read. The actual asynchronous behavior (Node.js not blocking while waiting) is identical either way.

What’s the difference between a promise being “pending” and “rejected”? > Pending means the operation hasn’t finished yet — no outcome is known. Rejected means it finished, and specifically failed, triggering .catch() instead of .then().

Do I need to understand callbacks before learning promises?

> It helps significantly — since promises exist specifically to solve a real problem callbacks introduce (nested complexity), understanding that original problem makes it far clearer why promises are structured the way they are, rather than just memorizing .then() syntax.

Originally published by Mr Madhukar

Read the complete article on Medium with full formatting & reader responses.