Async Code in Node.js: Callbacks and Promises

Search for a command to run...

No comments yet. Be the first to comment.
You are on a flight. Airplane mode is on. You text your friend. The app doesn't crash. It shows a clock icon. Later, you land. The message sends. How? Internet is unreliable. Tunnels, elevators, bad c
Direct DOM manipulation is slow. Updating the screen directly forces the browser to recalculate layouts and repaint pixels. Do this too often, your app lags. React solves this. How? The Virtual DOM. T
Building a todo app is easy. Building Instagram is hard. The difference isn't the code. It's the architecture. Let's look at how massive mobile apps scale using React Native and Expo Router. No fluff.
How to organize your code like a pro using ES6 modules The Problem: Why One Giant File Doesn’t Scale When you first start learning JavaScript, it’s tempting to put everything in a single app.js file.

Imagine you're building a web server that needs to read a file from disk. In a traditional, synchronous world, your code would look something like this:
const fs = require('fs');
const data = fs.readFileSync('myfile.txt', 'utf8');
console.log(data);
console.log('Server is running...');
This works fine for a simple script. But here's the problem: what if reading that file takes 2 seconds? Your entire server freezes for 2 seconds. Every user request gets stuck waiting. That's a disaster for a web application.
This is where asynchronous code comes in. Node.js was built from the ground up to handle async operations, allowing your server to keep working while waiting for slow tasks like file reading, database queries, or API calls to complete.
In this blog, we'll explore why async code exists in Node.js, how callbacks work, their problems, and how promises solve those issues.
Node.js runs on a single-threaded event loop. This means it has one main thread that executes your JavaScript code. At first glance, this sounds limiting. How can one thread handle thousands of concurrent users?
The secret is non-blocking I/O.
When Node.js encounters an I/O operation (like reading a file, querying a database, or making an HTTP request), it doesn't wait for it to finish. Instead, it:
Delegates the task to the system (or a thread pool)
Continues executing the rest of your code
Comes back to handle the result when the task is done
Think of Node.js like a coffee shop with one barista (the single thread). When a customer orders a latte:
Synchronous approach: The barista takes the order, makes the coffee, hands it over, then takes the next order. If making coffee takes 3 minutes, every other customer waits 3 minutes.
Asynchronous approach: The barista takes the order, starts the espresso machine (which works in the background), takes orders from other customers, and only returns to finish the latte when the machine beeps.
Node.js is the efficient barista. It keeps serving other customers while waiting for slow operations to complete.
Why does this matter?
Web servers handle lots of I/O (databases, files, APIs)
Async code lets one server handle thousands of concurrent connections
Your application stays responsive even under load
Before promises existed, callbacks were the primary way to handle async operations in Node.js. A callback is simply a function passed as an argument to another function, which gets called ("called back") when the async operation completes.
Let's revisit our file reading example using callbacks:
const fs = require('fs');
fs.readFile('myfile.txt', 'utf8', (error, data) => {
if (error) {
console.error('Failed to read file:', error);
return;
}
console.log('File contents:', data);
});
console.log('Server is running...');
fs.readFile() is called with a file path, encoding, and a callback function
Node.js starts reading the file in the background
The code immediately moves to the next line and prints "Server is running..."
When the file reading completes (maybe 2 seconds later), Node.js executes the callback function
The callback checks for errors and logs the file contents
Notice the output order:
Server is running...
File contents: [file data]
The "Server is running..." message appears before the file contents, even though it's written after fs.readFile() in the code. This is the essence of async execution!
Time →
[fs.readFile starts]
↓
[Console.log runs] → "Server is running..."
↓
[File reading completes in background]
↓
[Callback executes] → "File contents: ..."
Callbacks work fine for simple async operations. But real-world code often requires multiple async operations that depend on each other.
Imagine you need to:
Read a user file
Parse the user data
Read their preferences file
Update the database
With callbacks, this looks like:
const fs = require('fs');
fs.readFile('user.txt', 'utf8', (err, userData) => {
if (err) {
console.error('Error reading user:', err);
return;
}
const user = JSON.parse(userData);
fs.readFile('preferences.txt', 'utf8', (err, prefData) => {
if (err) {
console.error('Error reading preferences:', err);
return;
}
const preferences = JSON.parse(prefData);
db.updateUser(user.id, preferences, (err, result) => {
if (err) {
console.error('Error updating database:', err);
return;
}
console.log('User updated successfully:', result);
});
});
});
This nesting pattern is called "Callback Hell" or the "Pyramid of Doom" because the code forms a pyramid shape that:
Is hard to read: You're constantly indenting deeper
Is hard to maintain: Adding or removing steps requires restructuring
Makes error handling repetitive: Every callback needs its own error check
Obscures the logical flow: It's hard to see the sequence of operations
Forgetting error handling: Not checking err in every callback
Not returning after errors: Code continues executing after an error
Throwing exceptions in callbacks: Async errors can't be caught with try/catch
Losing context: Variables from outer scopes can be confusing
Promises were introduced to solve the problems with callbacks. A Promise is an object that represents the eventual completion (or failure) of an async operation and its resulting value.
A promise can be in one of three states:
Pending: Initial state, neither fulfilled nor rejected
Fulfilled (Resolved): Operation completed successfully
Rejected: Operation failed
[Pending]
↓
┌─────┴─────┐
↓ ↓
[Fulfilled] [Rejected]
↓ ↓
.then() .catch()
Modern Node.js provides promise-based versions of fs methods in fs.promises:
const fs = require('fs').promises;
fs.readFile('user.txt', 'utf8')
.then((userData) => {
const user = JSON.parse(userData);
return fs.readFile('preferences.txt', 'utf8');
})
.then((prefData) => {
const preferences = JSON.parse(prefData);
return db.updateUser(user.id, preferences);
})
.then((result) => {
console.log('User updated successfully:', result);
})
.catch((error) => {
console.error('An error occurred:', error);
});
fs.readFile() returns a Promise object immediately
.then() attaches a callback that runs when the promise fulfills
Each .then() can return a value (or another promise), which passes to the next .then()
.catch() handles any error that occurs in the chain
The code reads top-to-bottom, matching the logical flow
Flat structure: No more nesting/pyramid
Centralized error handling: One .catch() handles all errors
Chainable: Each .then() returns a promise for the next step
Readable: The sequence of operations is clear
Let's directly compare the callback and promise versions side by side:
fs.readFile('user.txt', 'utf8', (err, userData) => {
if (err) { /* handle */ return; }
const user = JSON.parse(userData);
fs.readFile('preferences.txt', 'utf8', (err, prefData) => {
if (err) { /* handle */ return; }
const preferences = JSON.parse(prefData);
db.updateUser(user.id, preferences, (err, result) => {
if (err) { /* handle */ return; }
console.log('Success:', result);
});
});
});
fs.readFile('user.txt', 'utf8')
.then(userData => JSON.parse(userData))
.then(user => fs.readFile('preferences.txt', 'utf8'))
.then(prefData => JSON.parse(prefData))
.then(preferences => db.updateUser(user.id, preferences))
.then(result => console.log('Success:', result))
.catch(error => console.error('Error:', error));
Readability: Linear, top-to-bottom flow vs. nested indentation
Error Handling: One .catch() vs. error checks in every callback
Composability: Easy to chain, combine, and reuse promises
Better Debugging: Stack traces are clearer
Standardization: Promises are part of JavaScript's core (ES6)
Foundation for async/await: Modern syntax builds on promises
Forgetting to return values: Each .then() must return a value or promise for the next step
Not handling rejections: Always include a .catch() or your errors will be silent
Mixing callbacks and promises: Stick to one pattern per codebase
Creating unnecessary promise wrappers: Use promise-based APIs when available
You've now learned:
Why Node.js uses async code (single-threaded, non-blocking I/O)
How callbacks work (functions passed to handle async results)
Problems with callbacks (nesting, callback hell, error handling)
How promises solve these issues (chaining, flat structure, centralized errors)
Why promises are better (readability, maintainability, standardization)
Promises are a huge improvement, but there's an even more modern syntax: async/await. It lets you write async code that looks synchronous, making it even easier to read and maintain.
Here's a sneak peek:
async function updateUser() {
try {
const userData = await fs.readFile('user.txt', 'utf8');
const user = JSON.parse(userData);
const prefData = await fs.readFile('preferences.txt', 'utf8');
const preferences = JSON.parse(prefData);
const result = await db.updateUser(user.id, preferences);
console.log('Success:', result);
} catch (error) {
console.error('Error:', error);
}
}
Async/await is built on top of promises, so understanding promises is essential. Once you're comfortable with promises, async/await will feel natural.