Async Code in Node.js: From Callback Hell to Promises

Audience: This post assumes familiarity with JavaScript fundamentals and basic Node.js. You don't need to know how the event loop works internally, but you should be comfortable writing functions and handling errors.
TL;DR: Node.js uses a single-threaded event loop that delegates I/O to the OS. Callbacks were the original way to handle async results — they work, but nest badly. Promises give you a flat, chainable alternative with better error propagation.
Problem
You need to read a config file, parse it, fetch a remote resource using values from that config, and write the result to disk. Every step depends on the previous one. Every step is asynchronous.
With callbacks, this turns into a pyramid of doom. With Promises, it's a clean chain. Understanding why requires looking at what Node.js is actually doing when it runs your code.
Why Async Code Exists in Node.js
Node.js runs on a single thread. That single thread runs your JavaScript. If it waits for a file to read or a network response to arrive, nothing else runs — no other requests, no timers, nothing.
So Node.js doesn't wait. Instead, it:
Registers the I/O operation with the operating system
Moves on to the next task
Gets notified when the I/O is done
Executes the handler you registered
This is the event loop. Your async handlers — callbacks or Promise handlers — run in response to events, not in sequence with the main flow.
This architecture is why Node.js can handle thousands of concurrent connections without threads. But it shifts the complexity to you: you have to write code that explicitly declares what to do after something happens.
Solution
Callback-Based Async Execution
A callback is just a function you pass to another function, to be called when an async operation completes.
Here's a real example — reading a file:
// callback-read.js
const fs = require('fs');
fs.readFile('./config.json', 'utf8', function(err, data) {
if (err) {
console.error('Failed to read config:', err.message);
return;
}
console.log('Config loaded:', data);
});
console.log('This runs before the file is read.');
Expected output:
This runs before the file is read.
Config loaded: { "apiUrl": "https://api.example.com", "timeout": 5000 }
The last console.log fires first because readFile is non-blocking. The callback fires later, when the OS delivers the file data.
Callback execution flow:
Main thread:
→ registers readFile with OS
→ logs "This runs before..."
→ event loop: checks for completed I/O
→ OS signals: file is ready
→ callback fires with (err, data)
This works. The problem starts when you chain operations.
The Nested Callback Problem
Now extend the scenario: read a config file, parse it, then write a result to disk.
// callback-chain.js
const fs = require('fs');
const https = require('https');
fs.readFile('./config.json', 'utf8', function(err, configData) {
if (err) {
console.error('Step 1 failed:', err.message);
return;
}
let config;
try {
config = JSON.parse(configData);
} catch (parseErr) {
console.error('Step 2 failed:', parseErr.message);
return;
}
https.get(config.apiUrl, function(response) {
let body = '';
response.on('data', function(chunk) {
body += chunk;
});
response.on('end', function() {
fs.writeFile('./result.json', body, 'utf8', function(writeErr) {
if (writeErr) {
console.error('Step 3 failed:', writeErr.message);
return;
}
console.log('Pipeline complete. Result written to result.json');
});
});
response.on('error', function(requestErr) {
console.error('Request failed:', requestErr.message);
});
}).on('error', function(err) {
console.error('HTTP error:', err.message);
});
});
This is already hard to follow. Each step pushes the code one level deeper. Error handling is scattered across multiple if (err) checks and .on('error') handlers. There's no single place where errors converge.
Add one more step and you have callback hell — not a metaphor, a real structural problem:
readFile(
parseConfig(
fetchData(
processData(
writeResult()
)
)
)
)
Debugging this means tracing execution across 4+ indentation levels. Refactoring means touching every nesting layer.
Promise-Based Async Handling
A Promise represents a value that will be available in the future. It exists in one of three states:
Pending → Fulfilled (resolved with a value)
→ Rejected (failed with a reason)
Once settled, a Promise never changes state. This predictability is what makes them composable.
Promise lifecycle:
new Promise(executor)
│
▼
[Pending]
│
┌────┴────┐
▼ ▼
[Fulfilled] [Rejected]
│ │
.then() .catch()
│ │
└──────┬───────┘
▼
next .then()
Here's the same file-read operation using fs.promises, the native Promise-based API:
// promise-read.js
const fs = require('fs').promises;
fs.readFile('./config.json', 'utf8')
.then(function(data) {
console.log('Config loaded:', data);
})
.catch(function(err) {
console.error('Failed to read config:', err.message);
});
console.log('This still runs before the file is read.');
Now rewrite the full pipeline with Promises:
// promise-chain.js
const fs = require('fs').promises;
const https = require('https');
function fetchUrl(url) {
return new Promise(function(resolve, reject) {
https.get(url, function(response) {
let body = '';
response.on('data', function(chunk) { body += chunk; });
response.on('end', function() { resolve(body); });
response.on('error', reject);
}).on('error', reject);
});
}
fs.readFile('./config.json', 'utf8')
.then(function(configData) {
return JSON.parse(configData);
})
.then(function(config) {
return fetchUrl(config.apiUrl);
})
.then(function(responseBody) {
return fs.writeFile('./result.json', responseBody, 'utf8');
})
.then(function() {
console.log('Pipeline complete. Result written to result.json');
})
.catch(function(err) {
console.error('Pipeline failed at some step:', err.message);
});
What changed:
Flat indentation regardless of how many steps you add
A single
.catch()handles errors from any step in the chainEach
.then()receives the resolved value of the previous stepJSON.parsethrows synchronously — Promise chains catch that too, because any exception thrown inside a.then()becomes a rejection
Why .catch() at the End Works
When you throw (or return a rejected Promise) inside a .then(), the next .then() in the chain is skipped and control jumps to the nearest .catch(). This is how a single error handler covers the entire pipeline.
Promise.resolve('start')
.then(function(value) {
throw new Error('something broke at step 1');
})
.then(function(value) {
// This is skipped
console.log('step 2:', value);
})
.catch(function(err) {
console.error('Caught:', err.message);
// Output: Caught: something broke at step 1
});
Callback vs Promise: Side-by-Side
| Property | Callbacks | Promises |
|---|---|---|
| Error handling | Per-callback if (err) |
Single .catch() |
| Nesting | Grows with each step | Flat .then() chain |
| Composability | Hard — requires utilities | Native .then() chaining |
| Sync exceptions | Uncaught unless try/catch | Automatically rejected |
| Readability at 5+ steps | Poor | Acceptable |
Results
Error handling goes from N scattered checks to 1
.catch()Adding a new async step means one new
.then(), not a new nesting levelSynchronous exceptions inside
.then()are automatically converted to rejections — no try/catch wrapper needed at each stepExisting Node.js built-ins (
fs.promises,dns.promises, etc.) expose native Promise APIs
Trade-offs
Promises are not free of issues:
Stack traces are weaker. When a Promise rejects, the stack trace often points to the internals of the Promise implementation, not the line that caused the problem. This improved in modern Node.js versions but is still occasionally frustrating.
Unhandled rejections crash the process. Since Node.js 15, an unhandled Promise rejection exits the process. This is the right behavior, but it catches teams off guard when migrating legacy code.
.then()chaining is still verbose. For complex flows,async/await(which compiles down to Promises) is cleaner. Promises are the foundation —async/awaitis the ergonomic layer on top.Callbacks are not obsolete. Event emitters (
EventEmitter), streams, and low-level APIs still use callbacks. You need to understand both.
Conclusion
Callbacks are the primitive mechanism Node.js uses to handle async results. They work, but they compose badly — each new step adds nesting and scatters error handling. Promises impose a contract: one resolved value or one rejection, propagated through a flat chain with a single error handler.
Understanding Promises is also the prerequisite for understanding async/await, which is just syntactic sugar over the same Promise machinery. Before you write await, you should know what it's waiting for.
Next step: Refactor the promise-chain.js example above using async/await and observe how the same Promise chain becomes synchronous-looking code without changing the underlying execution model.




