JavaScript Callbacks and the Pre-Promise Era: Why Async Needed Promises
Before Promises (ES2015) and async/await (ES2017), JavaScript's only model for asynchronous code was callbacks — a function you pass in, that runs when the work is done. Callbacks still power a lot of older code, Node.js APIs, and event listeners. Understanding them is essential for reading any pre-2015 codebase, working with Node.js, and appreciating why Promises were such a big deal.
This lesson covers what a callback is, the patterns that emerged for using them, the pain that earned the name "callback hell," the conventions that survived into modern Node, and the practical bridge from callbacks to Promises.
What a callback is #
A callback is just a function passed as an argument, to be called later. The pattern is older than JavaScript:
function processItem(item, callback) {
const result = item * 2;
callback(result);
}
processItem(5, (doubled) => {
console.log(doubled); // 10
});
That's it. The mechanism. The complexity comes from how callbacks scale when used for async work.
Synchronous vs asynchronous callbacks #
Callbacks come in two flavors. The flavor matters.
Synchronous callback — runs immediately #
[1, 2, 3].map(n => n * 2);
// `n => n * 2` runs three times, synchronously, before map returns
Array methods, sorting comparators, forEach — all sync callbacks. They run during the function call. No event-loop trip.
Asynchronous callback — runs later #
setTimeout(() => console.log('later'), 1000);
console.log('now');
// Logs 'now' first, then 'later' a second later
Async callbacks are queued on the event loop and run after the current call stack empties. Network requests, file I/O, timers, DOM events — all async callbacks.
The whole "how async JavaScript actually works" story lives in Lessons 5.5 (event loop) and 5.6 (execution context). For this lesson, the relevant point is: async callbacks come back at unpredictable times, and that's what creates the structural problems below.
The Node.js error-first convention #
Node.js settled on a single callback shape for nearly all its async APIs: the error-first callback. The first argument is an error (or null); the second is the result.
import fs from 'node:fs';
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) {
console.error('Failed to read:', err);
return;
}
console.log('Got:', data);
});
Why first? So you can't accidentally skip the error check. The standard pattern is always:
function handler(err, result) {
if (err) return handleError(err);
// happy path here
}
Many custom callback APIs adopted the same shape. Some did not — particularly DOM event listeners and setTimeout. Reading older code, you can tell roughly when something was written by which shape it uses.
Callback hell #
The original sin. Async work where each step depends on the previous one ends up nested like a staircase:
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) return console.error(err);
const config = JSON.parse(data);
fetchUsers(config.endpoint, (err, users) => {
if (err) return console.error(err);
enrichUsers(users, (err, enriched) => {
if (err) return console.error(err);
saveToFile(enriched, (err) => {
if (err) return console.error(err);
console.log('Done');
});
});
});
});
Five levels of indentation. Error handling repeated five times. No way to compose without more nesting. Any new step adds another level.
This style is called callback hell or the pyramid of doom. It's why Promises and async/await were such a relief when they arrived.
Mitigations that helped #
Before Promises landed, the community invented helpers:
// Named functions to flatten — works but boilerplate-heavy
fs.readFile('config.json', 'utf8', onConfig);
function onConfig(err, data) {
if (err) return console.error(err);
const config = JSON.parse(data);
fetchUsers(config.endpoint, onUsers);
}
function onUsers(err, users) {
if (err) return console.error(err);
enrichUsers(users, onEnriched);
}
function onEnriched(err, data) {
// ...
}
Or libraries like async:
import async from 'async';
async.waterfall([
(cb) => fs.readFile('config.json', 'utf8', cb),
(data, cb) => fetchUsers(JSON.parse(data).endpoint, cb),
(users, cb) => enrichUsers(users, cb),
(enriched, cb) => saveToFile(enriched, cb),
], (err) => {
if (err) console.error(err);
else console.log('Done');
});
These helped. Promises (and then async/await) solved the problem entirely.
From callbacks to Promises #
The modern Node.js APIs all return Promises:
import fs from 'node:fs/promises';
const data = await fs.readFile('config.json', 'utf8');
const config = JSON.parse(data);
const users = await fetchUsers(config.endpoint);
const enriched = await enrichUsers(users);
await saveToFile(enriched);
console.log('Done');
Same logic. Zero pyramid. One linear flow, errors propagate via try/catch (or to the caller, naturally).
We cover Promises in depth in Lesson 5.2 and async/await in 5.3.
Promisifying a callback API #
Lots of code in the wild still uses callbacks. If you need to use an old callback API in modern Promise-shaped code, wrap it:
import util from 'node:util';
const readFile = util.promisify(oldCallbackReadFile);
const data = await readFile('config.json', 'utf8');
util.promisify is Node's built-in. It takes a function that follows the error-first callback convention and returns a Promise-returning version.
If you're not in Node, or the callback doesn't follow the error-first shape, do it yourself:
function promisified(...args) {
return new Promise((resolve, reject) => {
oldFunction(...args, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
}
Where callbacks still make sense #
Callbacks didn't disappear. Three places they're still the right tool:
1. Synchronous transformation #
arr.map(fn);
arr.filter(fn);
arr.sort(compareFn);
No reason to add Promise overhead for instant operations.
2. Event listeners (multiple invocations) #
button.addEventListener('click', handler);
Promises are for single-value resolutions. Events fire many times — callbacks are the natural model.
3. Streaming / continuous data #
stream.on('data', chunk => { /* ... */ });
stream.on('end', () => { /* ... */ });
Same reason — many chunks, not a single value. Modern streaming APIs use AsyncIterables (Lesson 5.5) and for await, which is callback-like under the hood.
A summary #
- A callback is just a function passed as an argument, to be called later.
- Sync callbacks run during the call (
Array.map). Async callbacks run later via the event loop. - Node's error-first convention (
function (err, result)) became the de-facto standard in callback APIs. - Callback hell — nested async chains — drove the design of Promises.
- Promisify old callback APIs to use them with
async/await. - Callbacks still own sync transformation, event listeners, and streaming.
Reading older JavaScript code without this background is like reading Old English — recognizable but tricky. With it, every legacy library suddenly makes sense.
What's next #
Lesson 5.3 covers async/await — the syntax that finally made async code read like sync code. After that, Module 5 is complete: callbacks, Promises, async/await, error handling, the event loop, and the execution context, all in one path.
Try it yourself #
The sync-vs-async callback distinction is the easiest to feel. Predict the order:
console.log('A');
[1, 2].forEach(n => console.log('B' + n));
setTimeout(() => console.log('C'), 0);
console.log('D');js_sandboxOutput: A B1 B2 D C.forEach is synchronous — its callback runs immediately, twice, before D. setTimeout is async — even with a 0 ms delay, the callback waits for the current task to complete. So C lands last.That’s the sync-vs-async-callback distinction in one experiment. It explains why
setTimeout(fn, 0) isn’t “run immediately” — it’s “run as soon as the call stack is empty.”Know which kind of callback you're passing, and the entire async story becomes predictable.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


