Callbacks and Promises
Async Code in Node.js using callback and promises

Frontend Developer ๐ป | Fueled by curiosity and Tea โ | Always learning and exploring new technologies.
In our previous article we have seen that why we need async code. In this we will go through how to write async code
Callbacks
A callback is the simplest possible async mechanism: pass a function as an argument, and Node.js will call it when the async work is done.
Here's the pattern, using fs.readFile as the canonical example
const fs = require('fs');
fs.readFile('./config.json', 'utf8', function(err, data) {
if (err) {
console.error('Failed to read file:', err);
return;
}
console.log(data);
});
console.log('This runs immediately โ before the file is read');
Notice two things about this pattern:
Error-first convention. Node.js callbacks always receive the error as the first argument. If there's no error, it's null. If there is an error, the data argument will be undefined. This is called the "error-first" or "Node.js callback style." It's a convention, not enforced by the language but almost every Node.js built-in and npm package follows it. Always check err before using data.
Execution order. The console.log at the bottom runs before the file contents print. The readFile call registers the callback and returns immediately. The callback fires later, when the OS finishes reading. This is the async reordering you saw in the event loop article and it's always the behavior with callbacks.
Callbacks in Practice: A Real Scenario
Say you need to:
Read a user's config file
Parse it as JSON
Use the
userIdfrom the config to fetch user data from a databaseLog the user's name
const fs = require('fs');
fs.readFile('./config.json', 'utf8', function(err, data) {
if (err) {
console.error('Could not read config:', err);
return;
}
let config;
try {
config = JSON.parse(data);
} catch (parseErr) {
console.error('Config is not valid JSON:', parseErr);
return;
}
db.findUser(config.userId, function(err, user) {
if (err) {
console.error('Could not fetch user:', err);
return;
}
console.log('User:', user.name);
});
});
This works. It's perfectly valid Node.js code. But notice what happened to the structure: every async step requires nesting inside the previous one's callback. And we only have three operations. Real applications often have five, eight, ten.
Callback Hell
The nesting problem has a name in the Node.js community: callback hell โ sometimes called the "pyramid of doom" because of the shape the indentation makes.
Here's what five sequential async steps looks like with callbacks:
readFile(path, function(err, data) {
if (err) return handleError(err);
parseConfig(data, function(err, config) {
if (err) return handleError(err);
fetchUser(config.userId, function(err, user) {
if (err) return handleError(err);
fetchPermissions(user.id, function(err, perms) {
if (err) return handleError(err);
generateReport(user, perms, function(err, report) {
if (err) return handleError(err);
console.log(report);
});
});
});
});
});
This is technically correct but practically painful:
Every level of nesting adds cognitive load
Error handling must be repeated at every step
You can't reuse a step independently โ everything is tangled together
Adding a sixth step means another level of indentation
Debugging means mentally tracking which variables are in scope at which nesting depth
The code describes what happens in the right order, but it doesn't read that way. You can't scan it top-to-bottom and understand the flow linearly.
You can partially escape callback hell without Promises by naming your callbacks and defining them separately instead of inline. The same five-step sequence above can be written flat if you define each callback as a named function. It's not as ergonomic as Promises, but it's far more readable than nested inline functions. Many legacy Node.js codebases use exactly this pattern.
Promises
A Promise is an object that represents the eventual result of an async operation. Instead of passing a callback into a function, the function returns a Promise object that you work with.
The Promise can be in one of three states:
Pending โ the operation is still in progress
Fulfilled โ the operation succeeded; the result is available
Rejected โ the operation failed; an error is available
Once a Promise settles (fulfills or rejects), it stays that way forever. It won't randomly fulfill again, or change its result. This immutability is one of the things that makes Promises easier to reason about than callbacks.
Here's the same file-read example with a Promise-based API:
const fs = require('fs').promises;
fs.readFile('./config.json', 'utf8')
.then(function(data) {
console.log(data);
})
.catch(function(err) {
console.error('Failed to read file:', err);
});
The .then() runs when the Promise fulfills. The .catch() runs if it rejects. And critically you can chain them.
Chaining
Here's the power move. Instead of nesting, you chain .then() calls in a flat sequence:
const fs = require('fs').promises;
fs.readFile('./config.json', 'utf8')
.then(data => JSON.parse(data)) // step 2: parse
.then(config => db.findUser(config.userId)) // step 3: fetch user
.then(user => {
console.log('User:', user.name);
})
.catch(err => {
console.error('Something went wrong:', err); // catches errors from ANY step
});
Compare this to the callback version. It's the same five operations, written flat, with a single .catch() at the end that handles any error from any step.
This is why Promises were adopted so rapidly. The logic flows top to bottom. You can read it and understand the sequence. Error handling is centralized. Each .then() receives the return value of the previous one so passing data from step to step is just a matter of returning it.
Creating Your Own Promises
You won't just consume Promises โ you'll need to wrap older callback-based code in them. Here's how:
function readFileAsync(path) {
return new Promise(function(resolve, reject) {
fs.readFile(path, 'utf8', function(err, data) {
if (err) {
reject(err); // โ Promise rejects with the error
} else {
resolve(data); // โ Promise fulfills with the data
}
});
});
}
// Now you can use it with .then()/.catch()
readFileAsync('./data.txt')
.then(data => console.log(data))
.catch(err => console.error(err));
The new Promise(executor) pattern: inside the executor function, you call resolve(value) when the work succeeds and reject(error) when it fails. The Promise object carries that result forward.
What most tutorials skip: Node.js ships with util.promisify(), a built-in utility that automatically wraps any error-first callback function into a Promise-returning function. Instead of writing the wrapper above manually:
const { promisify } = require('util');
const readFileAsync = promisify(fs.readFile);
readFileAsync('./data.txt', 'utf8')
.then(data => console.log(data))
.catch(err => console.error(err));
One line. Works on any standard Node.js callback-style function. And fs.promises (used earlier) is essentially the entire fs module pre-promisified โ it's been available since Node 10 and is the modern default.
Promise.all: Running Multiple Async Operations in Parallel
This is one of the most useful patterns in real applications, and it has no clean equivalent in callback code.
Instead of chaining operations one after another, Promise.all runs multiple async operations simultaneously and waits for all of them:
const userPromise = db.findUser(userId);
const permsPromise = db.fetchPermissions(userId);
const prefsPromise = db.fetchPreferences(userId);
Promise.all([userPromise, permsPromise, prefsPromise])
.then(([user, perms, prefs]) => {
console.log(user.name, perms, prefs);
})
.catch(err => {
// If ANY of the three fails, this runs
console.error(err);
});
Three DB queries running at the same time. All three results available together when they all finish. If any one fails, the .catch() fires.
Without Promises, achieving this with callbacks requires a manual counter or an async utility library. With Promises, it's one line.
Callbacks were the original Node.js async mechanism โ simple, flexible, but painful to chain and error-handle at scale. They gave us "callback hell."
Promises replaced them with an object that represents a future value. .then() chains are flat and readable. .catch() handles errors from any step in the chain. Promise.all enables true parallelism in application code.
Understanding both is non-negotiable. You'll encounter callback-based APIs in older packages and existing codebases. You'll write and consume Promises in any modern Node.js project. And once you've internalized Promises, the final piece โ async/await โ clicks into place immediately.






