Async Code in Node.js: Callbacks and Promises

Node.js is built to never wait. Understanding how it handles async work is the key to writing good server-side JavaScript.
Why Async Code Exists in Node.js
Node.js runs on a single thread. That means it can only do one thing at a time, one piece of JavaScript running, one result coming back, one step at a time.
This sounds like a limitation. But it isn't, because of one important design decision: Node.js never blocks while waiting.
When your server needs to read a file, it doesn't sit there waiting for the disk to respond. When it queries a database, it doesn't freeze while the database thinks. It hands the job off to the system and moves on immediately. When the result comes back, Node.js picks it up and handles it.
This is the async model. And it's the reason a single Node.js server can handle thousands of simultaneous requests, not because it's doing them all at once, but because it never wastes time waiting.
Almost everything in Node.js that touches the outside world, files, networks, databases, timers, is async. And historically, the way you handled that was with callbacks.
The Starting Point: Reading a File
Let's use one concrete scenario throughout this article, reading a file from disk and using its contents. Simple enough to understand, but real enough to show actual problems.
In Node.js, the built-in fs module handles file operations. The basic file read looks like this:
const fs = require("fs");
fs.readFile("data.txt", "utf8", function(error, contents) {
if (error) {
console.error("Failed to read file:", error.message);
return;
}
console.log("File contents:", contents);
});
console.log("This runs before the file is ready.");
Output:
This runs before the file is ready.
(a moment later)
File contents: Hello from data.txt
The readFile call kicks off the disk read and immediately returns. Node.js moves on, that's why "This runs before the file is ready." prints first. When the file finishes loading, Node.js comes back and runs the callback function you passed in.
The callback function receives two arguments: error and contents. This is Node.js's standard error-first callback pattern. The first argument is always an error (or null if everything worked). The second argument is the result.
fs.readFile("data.txt", "utf8", callback)
│
▼
function(error, contents) {
if (error) { handle it }
else { use contents }
}
Step by step:
You call
fs.readFileand pass a callback functionNode.js starts reading the file in the background
Your code continues running (the line after
readFileruns immediately)When the file finishes loading, the callback runs
If reading failed,
errorhas the problem,contentsis undefinedIf it worked,
erroris null,contentshas the file data
This is the basic callback flow. One task, one callback, completely readable.
Callback-Based Async Execution: Step by Step
Let's build on the file read. Say you need to:
Read a config file
Use the config to find a data file
Read that data file
Each step depends on the result of the previous one. With callbacks, each step nests inside the previous one:
const fs = require("fs");
// Step 1: Read the config file
fs.readFile("config.json", "utf8", function(error, configData) {
if (error) {
console.error("Could not read config:", error.message);
return;
}
const config = JSON.parse(configData);
// Step 2: Use config to read the data file
fs.readFile(config.dataFile, "utf8", function(error, rawData) {
if (error) {
console.error("Could not read data file:", error.message);
return;
}
const data = JSON.parse(rawData);
console.log("Data loaded:", data);
});
});
This works. If you have two steps, it's manageable. But notice what's already happening, the second readFile is nested inside the first callback. Every dependent step pushes inward.
The Problem: Nested Callbacks
Now imagine a more realistic sequence. You need to:
Read a config file
Fetch user data based on config
Write a processed result to a new file
Send a confirmation log
const fs = require("fs");
fs.readFile("config.json", "utf8", function(error, configData) {
if (error) { console.error("Config failed:", error.message); return; }
const config = JSON.parse(configData);
fs.readFile(config.userFile, "utf8", function(error, userData) {
if (error) { console.error("User file failed:", error.message); return; }
const users = JSON.parse(userData);
const processed = users.map(u => ({ ...u, active: true }));
fs.writeFile("output.json", JSON.stringify(processed), function(error) {
if (error) { console.error("Write failed:", error.message); return; }
fs.appendFile("activity.log", "Processed users\n", function(error) {
if (error) { console.error("Log failed:", error.message); return; }
console.log("All done!");
// What if you needed one more step here?
});
});
});
});
Each step nests deeper. The code drifts right across the screen. This is Callback Hell, sometimes called the Pyramid of Doom.
readFile(config) ← level 1
readFile(users) ← level 2
writeFile(output) ← level 3
appendFile(log) ← level 4
done! ← level 5
It's not just ugly. It causes real problems.
Error handling repeats at every level. Four callbacks means four separate if (error) checks, each one slightly different, easy to forget, scattered throughout the code.
The logic is invisible. To understand the sequence, "read config, then read users, then write output, then log", you have to trace inward through the nesting. The steps aren't readable top to bottom. They're buried inside each other.
Reuse becomes almost impossible. That file-writing logic is trapped inside three levels of callbacks. You can't pull it out and use it somewhere else without restructuring the whole thing.
Debugging is painful. When something fails, the stack trace points deep into anonymous nested functions. Figuring out which callback failed and why takes much longer than it should.
Moving to Promises
Node.js recognized this problem. From version 10 onwards, most core APIs, including fs, offer a promise-based version through fs.promises. The same operations, but they return promises instead of requiring callbacks.
Here's the same single file read, using promises:
const fs = require("fs").promises;
fs.readFile("data.txt", "utf8")
.then(function(contents) {
console.log("File contents:", contents);
})
.catch(function(error) {
console.error("Failed:", error.message);
});
No callback function passed in. Instead, readFile returns a promise, and you chain .then() to handle the result and .catch() to handle errors.
Same operation. Cleaner structure. And it scales much better.
The Same Multi-Step Flow — With Promises
Now take the four-step callback pyramid and rewrite it with promises. Instead of nesting, each step chains onto the previous one:
const fs = require("fs").promises;
fs.readFile("config.json", "utf8")
.then(function(configData) {
const config = JSON.parse(configData);
return fs.readFile(config.userFile, "utf8"); // return next promise
})
.then(function(userData) {
const users = JSON.parse(userData);
const processed = users.map(u => ({ ...u, active: true }));
return fs.writeFile("output.json", JSON.stringify(processed)); // return next promise
})
.then(function() {
return fs.appendFile("activity.log", "Processed users\n");
})
.then(function() {
console.log("All done!");
})
.catch(function(error) {
console.error("Something failed:", error.message); // handles ALL errors
});
Compare the shape of the two versions:
Callbacks (pyramid): Promises (flat chain):
readFile(config) readFile(config)
readFile(users) .then(readFile users)
writeFile(output) .then(writeFile output)
appendFile(log) .then(appendFile log)
done! .then(done)
.catch(any error)
The logic is now linear. You read it top to bottom, each step leading into the next. One .catch() at the bottom handles any failure from any step in the chain.
How Promise Chaining Works in Node.js
The key to making the chain work is always returning the next promise from inside .then(). When you return a promise from .then(), the next .then() waits for that promise to settle before running.
fs.readFile("config.json", "utf8")
.then(function(configData) {
return fs.readFile("users.json", "utf8"); // ← returning a promise
// ↑ next .then() waits for this
})
.then(function(userData) {
// userData is the result of reading users.json
console.log(userData);
});
If you forget the return, the chain breaks, the next .then() runs immediately without waiting, and it receives undefined instead of the file contents.
// ❌ Broken — missing return
.then(function(configData) {
fs.readFile("users.json", "utf8"); // started but not returned!
})
.then(function(userData) {
console.log(userData); // undefined — didn't wait
})
Always return the next promise from inside .then().
Error Handling: The Big Win
This is where promises genuinely earn their place. In the callback version, every single callback had its own error check:
// Callbacks — error check at every level
fs.readFile("config.json", "utf8", function(err, data) {
if (err) { console.error(err); return; } // error 1
fs.readFile("users.json", "utf8", function(err, data) {
if (err) { console.error(err); return; } // error 2
fs.writeFile("output.json", data, function(err) {
if (err) { console.error(err); return; } // error 3
// finally do the thing
});
});
});
Three error handlers for three steps. Each one slightly different. Each one easy to miss.
With promises, one .catch() covers the entire chain:
// Promises — one error handler covers everything
fs.readFile("config.json", "utf8")
.then(data => fs.readFile("users.json", "utf8"))
.then(data => fs.writeFile("output.json", data))
.then(() => console.log("Done"))
.catch(error => {
// handles failure from ANY step above
console.error("Failed:", error.message);
});
If any step rejects, the chain jumps straight to .catch(). The intermediate .then() calls are skipped. One place to handle all failures, with the full error object, including which step failed.
Using async/await for Even Cleaner Code
Once you have promises, you're one small step from async/await, which makes the same promise-based code look like normal synchronous code:
const fs = require("fs").promises;
async function processFiles() {
try {
const configData = await fs.readFile("config.json", "utf8");
const config = JSON.parse(configData);
const userData = await fs.readFile(config.userFile, "utf8");
const users = JSON.parse(userData);
const processed = users.map(u => ({ ...u, active: true }));
await fs.writeFile("output.json", JSON.stringify(processed));
await fs.appendFile("activity.log", "Processed users\n");
console.log("All done!");
} catch (error) {
console.error("Something failed:", error.message);
}
}
processFiles();
Each await pauses the function until the promise resolves, without blocking Node.js's ability to handle other tasks in the meantime. The code reads exactly like the four-step description you'd give out loud: read config, read users, write output, log activity, done.
No nesting. No chain. No .then(). Just clean sequential code with one try/catch for error handling.
Callbacks vs Promises: Side by Side
CALLBACKS PROMISES
───────────────────── ─────────────────────
Structure: Nested (pyramid) Flat (chain)
Error handling: Repeated at every level One .catch() at the end
Readability: Drifts right as it grows Stays readable top-to-bottom
Reuse: Hard — logic is buried Easy — steps are independent
Node.js support: All versions fs.promises since Node v10
Debugging: Hard — anonymous nested Easier — linear flow
functions in stack traces
Key Takeaways
Node.js is single-threaded and handles async work by never blocking, it hands tasks off and comes back when results arrive.
The error-first callback pattern (
function(error, result)) is Node.js's original async convention.Callback hell happens when async tasks depend on each other and callbacks nest deeper with each step.
Problems with deep callbacks: repeated error handling, unreadable logic, impossible reuse, painful debugging.
fs.promises(available since Node.js v10) provides promise-based versions of all file system operations.With promises, async steps chain with
.then()instead of nesting, the code stays flat and readable.Always return the next promise inside
.then(), forgetting this breaks the chain silently.One
.catch()at the end of a chain handles errors from every step, far cleaner than repeated checks.async/await builds on promises to make the code look synchronous while still being fully async.
Quick Reference
const fs = require("fs");
const fsPromises = require("fs").promises;
// ── Callback style ────────────────────────────────────
fs.readFile("file.txt", "utf8", function(error, data) {
if (error) { console.error(error.message); return; }
console.log(data);
});
fs.writeFile("out.txt", "content", function(error) {
if (error) { console.error(error.message); return; }
console.log("Written");
});
// ── Promise style ─────────────────────────────────────
fsPromises.readFile("file.txt", "utf8")
.then(data => console.log(data))
.catch(error => console.error(error.message));
// Chain multiple steps
fsPromises.readFile("config.json", "utf8")
.then(data => {
const config = JSON.parse(data);
return fsPromises.readFile(config.dataFile, "utf8"); // must return!
})
.then(data => console.log(data))
.catch(error => console.error(error.message));
// ── async/await style ─────────────────────────────────
async function run() {
try {
const data = await fsPromises.readFile("file.txt", "utf8");
console.log(data);
await fsPromises.writeFile("out.txt", data);
console.log("Done");
} catch (error) {
console.error(error.message);
}
}
run();
Callbacks were the start. Promises made them manageable. async/await made them invisible. But they're all solving the same problem, how to handle work that takes time without stopping everything else.
