Async Code in Node.js: Callbacks and Promises Explained with Real Examples

TL;DR: Node.js uses a single-threaded event loop, so async code isn't optional — it's the foundation. Callbacks were the original async primitive but collapse into unreadable nesting. Promises provide a structured, chainable alternative that handles errors consistently and scales to complex workflows.
Audience: This post assumes working knowledge of JavaScript and basic Node.js. You don't need to understand the event loop deeply, but you should know what a function is and have written at least one Node.js script.
Problem
Suppose you need to read a config file, parse it, then use those values to fetch data from an external API. Three operations, each depending on the previous one.
In a synchronous language, this is trivial:
# Python — synchronous
config = read_file("config.json")
api_url = parse(config)["api_url"]
data = fetch(api_url)
Each line blocks until complete. Simple, linear, predictable.
In Node.js, blocking the thread while reading a file or making a network call would freeze every other request being handled by the same process. Node.js has one thread. If that thread waits, nothing else runs.
This is why async code exists in Node.js — not as a stylistic preference, but as an architectural requirement.
How Node.js Handles Async Work
Node.js delegates I/O operations (file reads, network calls, timers) to the underlying OS or a thread pool via libuv. When the operation completes, the result is placed into an event queue. The event loop picks it up and executes your callback.
[Your Code] --> [libuv / OS] --> [Event Queue] --> [Event Loop] --> [Callback Executed]
Your JavaScript never waits. It registers a "call me when done" instruction and moves on.
Solution Part 1: Callback-Based Async
A callback is a function you pass as an argument. Node.js calls it when an async operation finishes.
Reading a File with Callbacks
// file: read-config-callback.js
const fs = require('fs');
fs.readFile('./config.json', 'utf8', function(error, fileContent) {
if (error) {
console.error('Failed to read config:', error.message);
return;
}
console.log('Config loaded:', fileContent);
});
console.log('This runs BEFORE the file content is printed.');
Expected output:
This runs BEFORE the file content is printed.
Config loaded: { "api_url": "https://api.example.com" }
The console.log after readFile executes immediately because readFile is non-blocking. The callback fires later, once the OS returns the file data.
The Callback Convention in Node.js
Node.js follows a consistent pattern called error-first callbacks:
function callback(error, result) { ... }
First argument: an
Errorobject if something went wrong,nullotherwiseSecond argument: the successful result
This is a convention, not enforced by the runtime. Every built-in Node.js async function follows it.
Step-by-Step Callback Execution Flow
1. fs.readFile() called
└── Node hands the read operation to libuv
└── JavaScript continues executing the next line
2. 'This runs BEFORE...' is printed
3. File read completes (OS notifies libuv)
└── Callback placed in event queue
4. Event loop picks up callback
└── Calls your function with (null, fileContent)
5. Your callback runs, prints config content
The Real Problem: Callback Hell
Now implement the original scenario — read file, parse config, fetch API data — using callbacks:
// file: callback-hell.js
const fs = require('fs');
const https = require('https');
fs.readFile('./config.json', 'utf8', function(readError, fileContent) {
if (readError) {
console.error('Read failed:', readError.message);
return;
}
let config;
try {
config = JSON.parse(fileContent);
} catch (parseError) {
console.error('Parse failed:', parseError.message);
return;
}
const apiUrl = config.api_url + '/users';
https.get(apiUrl, function(response) {
let body = '';
response.on('data', function(chunk) {
body += chunk;
});
response.on('end', function() {
try {
const users = JSON.parse(body);
console.log('Users fetched:', users.length);
} catch (jsonError) {
console.error('Response parse failed:', jsonError.message);
}
});
}).on('error', function(requestError) {
console.error('Request failed:', requestError.message);
});
});
This is three levels of nesting, and it's already difficult to follow. Error handling is duplicated and scattered. Add one more async step and readability collapses.
The structural problems with nested callbacks:
Error handling is not centralized — each level needs its own error check
Horizontal growth — each new step pushes code further right
Control flow is hard to trace — the logical sequence is buried inside function nesting
Reuse is difficult — extracting parts of the chain requires careful wiring
Solution Part 2: Promise-Based Async
A Promise is an object that represents the eventual result of an async operation. It can be in one of three states:
[Pending] --> [Fulfilled] (value available)
--> [Rejected] (error occurred)
Once a Promise settles (fulfills or rejects), it never changes state again.
Promise Lifecycle
new Promise((resolve, reject) => {
// async work happens here
// call resolve(value) on success
// call reject(error) on failure
})
.then(value => { /* handle success */ })
.catch(error => { /* handle any error in the chain */ })
.finally(() => { /* always runs */ });
Promisifying File Read
Node.js includes fs.promises which exposes Promise-based versions of all file system methods:
// file: read-config-promise.js
const fs = require('fs').promises;
fs.readFile('./config.json', 'utf8')
.then(function(fileContent) {
console.log('Config loaded:', fileContent);
})
.catch(function(error) {
console.error('Failed to read config:', error.message);
});
console.log('This still runs before the file is read.');
Same async behavior. Cleaner shape.
Implementing the Full Scenario with Promises
// file: promise-chain.js
const fs = require('fs').promises;
const https = require('https');
// Wrap https.get in a Promise since it uses callbacks internally
function fetchJson(url) {
return new Promise((resolve, reject) => {
https.get(url, (response) => {
let body = '';
response.on('data', (chunk) => { body += chunk; });
response.on('end', () => {
try {
resolve(JSON.parse(body));
} catch (error) {
reject(new Error('Failed to parse response JSON'));
}
});
}).on('error', reject);
});
}
fs.readFile('./config.json', 'utf8')
.then((fileContent) => JSON.parse(fileContent))
.then((config) => fetchJson(config.api_url + '/users'))
.then((users) => {
console.log('Users fetched:', users.length);
})
.catch((error) => {
// One handler catches errors from ANY step above
console.error('Pipeline failed:', error.message);
});
Expected output (with valid config and API):
Users fetched: 42
Why This Is Better
Readability: The logical sequence reads top-to-bottom. Each .then() is one step in the pipeline.
Centralized error handling: One .catch() at the end handles failures from any step. If JSON.parse throws, if the file doesn't exist, or if the network request fails — all route to the same handler.
Composability: You can extract fetchJson and reuse it anywhere. The chain remains clean.
Side-by-Side Comparison
CALLBACK APPROACH PROMISE APPROACH
───────────────────────────────── ─────────────────────────────────
readFile(..., function(err, data) { readFile(...)
if (err) { handle } .then(data => parseConfig(data))
parseConfig(data, function(err) { .then(config => fetchApi(config))
if (err) { handle } .then(result => process(result))
fetchApi(..., function(err) { .catch(err => handleAnyError(err))
if (err) { handle }
process(result);
});
});
});
The Promise chain is flat. Every step is at the same indentation level. Error handling is consolidated.
Results
Error handling consolidation: 4 separate error checks collapsed into 1
.catch()Nesting depth: Reduced from 4 levels deep to 1
Readability: Each async step is one line in a linear chain
Testability: Individual steps (like
fetchJson) are isolated and independently testable
Trade-offs
Promises are not a silver bullet:
| Issue | Detail |
|---|---|
| Unhandled rejections | A Promise without .catch() silently swallows errors in older Node versions. Always attach a .catch(). |
| Debugging stack traces | Stack traces from rejected Promises can be harder to read than synchronous exceptions. |
| Wrapping legacy code | APIs using callbacks need manual wrapping. util.promisify in Node.js automates this for error-first callbacks. |
| Memory | Long-lived Promise chains can hold references longer than equivalent callbacks. Rare in practice, but relevant in high-throughput systems. |
When callbacks still make sense:
Interfacing with low-level C++ addons that expose callback-only APIs
Event emitters (which are inherently multi-fire and don't map to Promises)
Simple, single-step async with no chaining
Wrapping Callbacks with util.promisify
Rather than manually wrapping callback-based functions:
// file: promisify-example.js
const fs = require('fs');
const { promisify } = require('util');
// fs.readFile uses error-first callback convention
const readFileAsync = promisify(fs.readFile);
readFileAsync('./config.json', 'utf8')
.then((content) => console.log('Content length:', content.length))
.catch((error) => console.error('Read error:', error.message));
util.promisify works with any function that follows the (error, result) callback convention. It's the fastest path to modernizing callback-based code.
Conclusion
Callbacks are not broken — they're the direct expression of how Node.js's event loop works. But they don't compose well, and error handling becomes inconsistent the moment you chain more than two operations.
Promises give you the same non-blocking execution with a flat, readable structure and centralized error handling. They're also the foundation for async/await, which is syntactic sugar over Promises and worth learning next.
Start with fs.promises for file operations, use util.promisify for legacy callback functions, and consolidate error handling in a single .catch() at the end of every chain.
Further Reading
Promises/A+ Specification — the standard all JS Promise implementations follow




