Concept
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.
function getUser(id, callback) {
setTimeout(() => {
callback(null, { id, name: "Ada" }); // (error, result), the classic Node convention
}, 100);
}
getUser(1, (err, user) => {
if (err) return console.error(err);
console.log(user.name);
});This works for one async step. It falls apart the moment you need several in sequence.
Callback hell
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 (err) return handleError(err);
renderPage(user, posts, comments); // 3 levels deep, and growing
});
});
});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 aboveGeneration 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.
Try It
This callback-based function has a subtle bug related to error handling. Find it before checking the solution.
function loadUserProfile(userId, callback) {
fetchUser(userId, (err, user) => {
if (err) {
callback(err);
}
fetchAvatar(user.id, (err, avatar) => {
callback(null, { user, avatar });
});
});
}Solution
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.
Implement It Yourself
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);
});
});
};
}
function readFileCallback(path, callback) {
setTimeout(() => callback(null, `contents of ${path}`),
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.
In React
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.
Common Mistakes
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.
3. Swallowing errors silently
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.
Best Practices
- 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
promisifyonce at the boundary. - Always handle the error path, in every generation, a callback without error handling, a promise chain without
.catch(), or anasyncfunction withouttry/catch(or an unhandled-rejection handler) will all eventually bite you in production.
Performance Tips
- 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/awaitover 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).
