DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/JavaScript/Async Programming Overview
beginner20 min read·Updated Jun 2025

Async Programming Overview

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.

Related topics

PromisesExecution ContextProEvent LoopPro

On this page

20m
Knowledge CheckInterview QuestionsCheatsheet

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



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.


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);
















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 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.

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/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);
}
const readFilePromise = promisify(readFileCallback);
readFilePromise("data.txt").then((contents) => console.log(contents));
// or, with async/await:
async function main() {
const contents = await readFilePromise("data.txt");
console.log(contents);
}