Before promises and async/await existed, every async operation in JavaScript was handled with callbacks — and callbacks alone don't scale. Understanding why the language evolved the way it did makes every later async pattern feel inevitable rather than arbitrary.
asynccallbackscallback-hellhistoryfundamentals
Knowledge Check
4 questions · pass at 70%
0/4
easymcq
1. What is 'callback hell'?
Interview Questions
2 questions
Cheatsheet
Download-ready reference
Cheatsheet
Three generations, same underlying event loop — evolution is about
ERGONOMICS and ERROR HANDLING, not speed:
1. Callbacks: fn(args, (err, result) => {...})
Problem: nested pyramid, manual error handling at every level
2. Promises: fn(args).then(result => {...}).catch(err => {...})
Fix: flat chaining, ONE .catch() for the whole chain
Problem: still visually a chain; awkward with loops/branches
3. async/await: const result = await fn(args); // inside try/catch
Fix: reads like sync code, familiar control flow, one try/catch
Built ENTIRELY on top of promises — not a separate mechanism
"Releasing Zalgo" = a function that calls its callback sometimes
synchronously, sometimes asynchronously — always pick ONE and be consistent.
Missing `return` after an error callback = the #1 real-world callback bug.
The beginner framing: "async" code is code that doesn't finish immediately — it kicks off some operation (a timer, a network request, a file read) and lets the rest of your program keep running, dealing with the result later, whenever it actually arrives.
The precise mental model: JavaScript needed an async story from day one, because it originally ran almost exclusively in browsers, where blocking the single thread for even a second freezes the entire page (see Event Loop for the full mechanism behind why nothing blocks). The language's answer has gone through three distinct generations, each solving a real problem with the previous one:
Generation 1 — Callbacks (1995–2015-ish): pass a function to be called later, when the result is ready.
getUser(1, (err, user) => { if (err) return handleError(err); getPosts(user.id, (err, posts) => { if (err) return handleError(err); getComments(posts[0].id, (err, comments) => { if
Every additional async step nests one level deeper — "the pyramid of doom." Worse than the visual mess: error handling has to be manually repeated at every level, and there's no single place to catch a failure from any step.
Generation 2 — Promises (ES2015): an object representing a value that will exist eventually, with .then() chains that stay flat regardless of how many steps you add. See Promises for the full deep dive.
getUserPromise(1) .then((user) => getPostsPromise(user.id)) .then((posts) => getCommentsPromise(posts[0].id)) .then((comments) => renderPage(comments)) .catch(handleError); // ONE catch handles a failure from ANY step above
Generation 3 — async/await (ES2017): syntax that lets promise-based code read like synchronous code, while remaining exactly as non-blocking underneath. See Async/Await.
async function loadPage() { try { const user = await getUserPromise(1); const posts = await getPostsPromise(user.id); const comments = await getCommentsPromise(posts[0].id); renderPage(comments); } catch (err) { handleError(err); // familiar try/catch, works across every awaited step
Each generation didn't replace the one before it technically — promises are built on the same callback-and-queue machinery underneath, and async/await is built directly on promises. What changed each time was the ergonomics: making sequential async logic easier to read, and error handling easier to centralize.
Missing a return after callback(err). If fetchUser fails, the code calls callback(err) — but then keeps executing, calling fetchAvatar(user.id, ...) with user being undefined (since the fetch failed), crashing with a TypeError on user.id. The fix:
if (err) { return callback(err); // stop execution here on failure}
This exact class of bug — forgetting to return after handling an error in a callback — is one of the most common real-world callback-hell bugs, and it's part of why (one handler, impossible to "forget" to stop execution after) was such a genuine improvement.
Convert a callback-based function into a promise-returning one — the exact transformation Node's util.promisify performs automatically:
function promisify(fn) { return function (...args) { return new Promise((resolve, reject) => { fn(...args, (err, result) => { if (err) reject(err); else resolve(result);
This assumes the classic Node "error-first callback" convention (callback(error, result)) — which is exactly why that convention became standard: it made this exact wrapper possible to write once and reuse everywhere.
Data fetching in React has followed the same three-generation arc. Early React code fetched data with callback-style APIs (XMLHttpRequest.onload) inside lifecycle methods; then promises and fetch() became standard inside componentDidMount/useEffect; today, most non-trivial apps reach for a data-fetching library (TanStack Query, SWR, or framework-level solutions like Next.js Server Components and use()) that hides the promise/async-await machinery behind hooks. Regardless of the abstraction layer, everything underneath is still built on the exact promise and callback fundamentals covered in this path — a useQuery hook is, at its core, an async function's result plumbed into component state.
1. Calling a callback synchronously in some cases, asynchronously in others#
function getValue(useCache, callback) { if (useCache) { callback(cachedValue); // synchronous! } else { fetchValue((value) => callback(value)); // asynchronous }}
This is a real, well-documented anti-pattern ("releasing Zalgo") — code that calls the same function expects consistent timing. If a callback sometimes fires synchronously and sometimes asynchronously, calling code can't reliably reason about execution order (e.g., code after getValue(...) might run before OR after the callback, unpredictably). Always be consistently sync or consistently async.
2. Nesting instead of chaining once promises are available#
// Still "callback hell," just with promises instead of callbacks:getUserPromise(1).then((user) => { getPostsPromise(user.id).then((posts) => { getCommentsPromise(posts[0].id).then((comments) => { renderPage(comments); }); });});
Promises only solve the pyramid problem if you actually chain them (return each promise from the previous .then()), rather than nesting new .then() calls inside old ones.
getUser(1, (err, user) => { // forgot to check `err` at all — a failed request silently // proceeds with `user === undefined`, crashing somewhere unrelated later console.log(user.name);});
Every callback-based async call needs its error case handled explicitly — there's no automatic propagation like .catch() or try/catch gives you with promises and async/await.
Prefer async/await for new code — it's built on promises, which are built on the same queue mechanism as callbacks, but it reads top-to-bottom and centralizes error handling in a single try/catch.
Never mix callback style and promise style in the same function without a clear boundary — pick one, and if you must interoperate with a callback API, wrap it with promisify once at the boundary.
Always handle the error path, in every generation — a callback without error handling, a promise chain without .catch(), or an async function without try/catch (or an unhandled-rejection handler) will all eventually bite you in production.
None of these three generations differ in raw performance — they're all built on the identical event-loop queue mechanism underneath (see Event Loop). Choosing async/await over raw callbacks is a readability and maintainability decision, not a speed one.
The real performance lever in async code is whether independent operations run sequentially or in parallel — a mistake that survives all three generations equally (see the sequential-await mistake covered in Async/Await).
(err)
return
handleError
(err);
renderPage(user, posts, comments); // 3 levels deep, and growing
});
});
});
}
}
.catch()
});
});
};
}
function readFileCallback(path, callback) {
setTimeout(() => callback(null, `contents of ${path}`), 100);