The original async pattern: pass a function to be called when the work is done. Simple — until you nest five of them.
Error-first callbacks (Node convention)
function readUser(id, callback) {
setTimeout(() => {
if (id <= 0) callback(new Error("invalid id"));
else callback(null, { id, name: "User" + id });
}, 10);
}
readUser(7, (err, user) => {
if (err) return console.log("failed:", err.message);
console.log("got", user);
});
readUser(-1, (err, user) => {
if (err) return console.log("failed:", err.message);
console.log("got", user);
});got { id: 7, name: 'User7' }
failed: invalid idCallback hell (the pyramid of doom)
const step = (name, cb) => setTimeout(() => cb(null, name), 5);
step("login", (err, a) => {
step("load profile", (err, b) => {
step("load orders", (err, c) => {
step("render", (err, d) => {
console.log([a, b, c, d].join(" → "));
});
});
});
});login → load profile → load orders → render| Problem | Why it hurts |
|---|---|
| Nesting | Code drifts right; hard to read and refactor |
| Error handling | Must check err at every level; try/catch cannot catch errors thrown in later callbacks |
| Inversion of control | You trust the callee to call you exactly once, not zero or twice, not sync and async |
| Composition | Parallel work, timeouts and "first one wins" need hand-written bookkeeping |
Promisify
import { promisify } from "node:util";
function legacyAdd(a, b, cb) { setTimeout(() => cb(null, a + b), 5); }
const addAsync = promisify(legacyAdd);
console.log(await addAsync(2, 3));
const manual = (a, b) =>
new Promise((resolve, reject) => legacyAdd(a, b, (err, v) => (err ? reject(err) : resolve(v))));
console.log(await manual(10, 20));5
30What is callback hell and how do you avoid it?