Asynchronous JavaScript Explained: Callbacks to Async/Await

Updated on December 10, 2025 9 minutes read


If you are just getting started with programming, you probably imagine your code as a series of steps that run one after another. Step A finishes, then step B starts, then step C, and so on. For many simple programs, that model is enough and keeps things easy to write and debug.

By 2026, however, most real‑world JavaScript apps need to react to users, networks, and external services at the same time. If everything runs strictly in order, your interface can freeze, and your users are left staring at a stuck screen. Asynchronous JavaScript exists to avoid that experience.

In this guide, we will look at what asynchronous code is, how it works in JavaScript, and how to use callbacks, promises, and async/await without getting lost in “callback hell”.

Synchronous vs asynchronous code

In a synchronous program, each statement blocks the next one. If a function takes five seconds to finish, the rest of your code simply waits for five seconds too. During that time, your user interface does not respond, and the app feels laggy or frozen.

Asynchronous code lets slow tasks start and then continue in the background. While they run, JavaScript can keep processing other work, such as user input or rendering. When the slow task finishes, your code is notified and can react to the result.

You can think of it like running a washing machine while you cook. You start one task, move on to another, and only come back when the machine beeps to say it is done.

A running example: the book reader app

Imagine a book reader app with features such as finding all occurrences of a word and jumping between bookmarks. A user is reading a long book and searches for every occurrence of the word “The”. Scanning the entire book might take a couple of seconds.

In a purely synchronous design,gn the app, would run the search first and only afterwards handle anything else. While the search is running, the user cannot turn a page, highlight text, or open the bookmark menu. The app seems frozen even though it is just busy.

That kind of laggy experience is exactly what asynchronous programming tries to avoid.

In our exam, please note that the long‑running search and the bookmark navigation are independent. The user does not need to know the total number of matches to go to the next bookmark. That means these operations do not actually have to run one after another.

A better design is to start the search in the background, keep the app responsive, and notify the user when the search is done.

With this flow, the user can continue reading while the search continues. When the search finishes, the app surfaces the results. This is the core idea behind asynchronous programming.

How JavaScript handles asynchronous work

JavaScript has a single main thread that runs your code line by line. Under the hood, browsers and Node.js provide a set of Web APIs or platform features that can handle slow work,, such as timers, network requests, and file access.

When you call one of those APIs, you usually pass it a function that should run later. The platform queues that function, and the JavaScript event loop eventually picks it up when the call stack is free. That queued function is called a callback.

By 2026, almost every modern browser and Node.js version will support a rich set of asynchronous APIs. The three main patterns you will see are callbacks, promises, and async/await. Let’s go through each one.

Callbacks

In a typical program, you call a function, it runs, and it returns a value. Asynchronous programming twists that slightly. Instead of returning a value immediately, the function accepts another function to call later when the work is done. That second function is the callback.

Here is a simple callback‑based version of our search example:

// Search occurrences function with a callback
function searchOccurrences(word, callback) {
  try {
    // Simulate some long-running search logic
    // In a real app, this might be an API call or file read
    const result = ['the fox', 'the river', 'the book'];

    // No error, so we pass null as the first argument
    callback(null, word, result);
  } catch (err) {
    // Something went wrong, so we pass the error
    callback(err);
  }
}

// Search occurrences callback function
function handleSearchOccurrencesResult(err, word, result) {
  if (err) {
    console.log(`Search operation for "${word}" ended with an error`);
  } else {
    console.log(`Search results for "${word}": ${result.join(', ')}`);
  }
}

searchOccurrences('the', handleSearchOccurrencesResult);

The searchOccurrences function takes two arguments: the word to search for and a callback. Inside the function, we do some work. When everything goes well, we call the callback with null as the error, followed by the word and the result. If an error happens, we catch it and call the same callback with the error. This pattern, “error as the first argument and data after”, is very common in Node. JS-style callback code is often called the error‑first callback convention.

In the callback function handleSearchOccurrencesResult,,t we check whether err is set. If there is an error, we log a message. Otherwise, we print the search result. The important point is that searchOccurrences does not have to block the rest of the program while it runs.

Scope and timing issues with callbacks

There is a common beginner mistake with callbacks. It happens when you try to use a value that is produced asynchronously outside of the callback itself.

let result;

function searchOccurrences(word, callback) {
  // Simulate asynchronous behavior with setTimeout
  setTimeout(() => {
    try {
      const searchResult = ['the fox', 'the river', 'the book'];

      result = searchResult;
      callback(null, word, result);
    } catch (err) {
      callback(err);
    }
  }, 1000); // 1 second delay
}

function handleSearchOccurrencesResult(err, word, searchResult) {
  if (err) {
    console.log(`Search operation for "${word}" ended with an error`);
  } else {
    console.log(`Search results for "${word}": ${searchResult.join(', ')}`);
  }
}

searchOccurrences('the', handleSearchOccurrencesResult);

console.log(result);

At first glance, it looks like result should contain the search results. In reality,y, console.log(result) runs before the asynchronous work finishes, so result is still undefined at that point.

The lesson is simple but important: keep the logic that uses asynchronous results inside the callback or in code that is triggered from that callback. Otherwise, you will run into subtle timing bugs that are hard to debug.

The problem: callback hell

Callbacks themselves are not bad. The trouble starts when you have to chain several asynchronous operations that depend on each other. You end up with deeply nested code that is hard to read and even harder to maintain.

functionA(function (err, resA) {
  if (err) {
    return handleError(err);
  }

  functionB(resA, function (err, resB) {
    if (err) {
      return handleError(err);
    }

    functionC(resB, function (err, resC) {
      if (err) {
        return handleError(err);
      }

      functionD(resC, function (err, resD) {
        if (err) {
          return handleError(err);
        }

        console.log('All done', resD);
      });
    });
  });
});

Every level adds a new callback, a new error check, and more indentation. This pattern is often called “callback hell” or “the pyramid of doom”.

Promises were introduced to make this kind of logic easier to manage.

Promises

A promise is an object that represents a value that may not be available yet. It can be in one of three states: pending, fulfilled, or rejected. Instead of passing a callback into a function, the function returns a promise that you can attach handlers to.

Here is our search example rewritten with promises:

// Search occurrences function that returns a promise
function searchOccurrences(word) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      try {
        // Simulate some long-running search logic
        const result = ['the fox', 'the river', 'the book'];

        // Resolve with an object that includes both word and result
        resolve({ word, result });
      } catch (err) {
        reject(err);
      }
    }, 1000);
  });
}

searchOccurrences('the')
  .then(({ word, result }) => {
    console.log(`Search results for "${word}": ${result.join(', ')}`);
  })
  .catch((err) => {
    console.log('Search operation ended with an error', err);
  });

The searchOccurrences function now returns a new Promise. Inside the promise executor, we call resolve when the work finishes successfully and reject when something goes wrong. On the caller side, we use .then to attach a handler for the fulfilled state and .catch for errors. This keeps success logic and error logic clearly separated and makes the flow easier to follow, especially compared to nested callbacks.

Chaining promises to avoid callback hell

One of the biggest advantages of promises is that you can chain them. Each .then returns a new promise, so you can line up asynchronous operations in sequence without deeply nested callbacks.

searchOccurrences('the')
  .then(() => searchOccurrences('asynchronous'))
  .then(() => searchOccurrences('javascript'))
  .then(() => searchOccurrences('guide'))
  .then(() => {
    console.log('Finished all searches');
  })
  .catch((err) => {
    console.log('One of the search operations ended with an error', err);
  });

In this example, each .then waits for the previous promise to resolve before starting the next search. The overall flow is easier to read, and there is a single .catch at the end to handle any error that occurs in the chain.

Promises are the foundation that async/await builds on. Async/await is just a friendlier syntax on top of this mechanism.

Async/await

Async/await adds a thin layer of syntax on top of promises to make asynchronous code look more like synchronous code. It does not change how JavaScript works under the hood. The event loop and promises are still doing the real work.

You mark a function as asynchronous with the async keyword. Inside that function, you can use await to pause until a promise settles and unwrap its value.

async function searchOccurrences(word) {
  try {
    // Simulate some long-running search logic
    const result = ['the fox', 'the river', 'the book'];

    return result;
  } catch (err) {
    console.log(`Search operation for "${word}" ended with an error`);
    throw err; // rethrow so callers can handle it
  }
}

async function run() {
  const word = 'the';
  const result = await searchOccurrences(word);

  console.log(`Search results for "${word}": ${result.join(', ')}`);
}

run().catch((err) => {
  console.log('Search operation failed', err);
});

The async keyword tells JavaScript that searchOccurrences returns a promise. Inside run, we use await to pause until that promise is fulfilled and assign its value to result.

From the caller side, the code looks almost like ordinary synchronous code. The big difference is that while run is paused on await, the JavaScript event loop can continue to handle other work. The main thread is not blocked, so your application stays responsive even during long‑running operations.

When to use callbacks, promises, or async/await

In modern JavaScript, you will still encounter all three patterns:

  • Many older libraries are callback‑based, especially in legacy Node.js code bases.
  • Promise‑based APIs are common in browsers and Node.js, for example, fetch or fs.promises.
  • Async/await is the most ergonomic way to write new asynchronous code in 2026.

As a rule of thumb, prefer async/await for new code because it reads like synchronous code. When you work with callback‑style APIs, you can often wrap them in a promise and then use async/await on top.

Wrap up

In thisarticlee we explored why the simple step‑by‑step model of programming does not always work for user‑facing apps. Long‑running tasks such as search, network calls, or file access can make interfaces feel slow or frozen.

We then looked at three core tools for asynchronous JavaScript: callbacks, promises, and async/await. Each builds on the previous one to make complex flows easier to express and reason about as your applications grow.

Asynchronous logic shows up everywhere, from calling external APIs to querying databases or waiting for user events in a browser. With the patterns in this guideeguidee better equipped to write clean, maintainable asynchronous JavaScript in 2026 and beyond.

If you enjoyed this guide, explore more articles on the CLA Blog, where we share practical tips on getting into tech and growing your skills. To go further, future‑proof your career by upskilling in HTML, CSS, and JavaScript with Code Labs Academy’s Web Development Bootcamp.

Frequently Asked Questions

What is asynchronous JavaScript in simple terms?

Asynchronous JavaScript is code that can start a task, let other work continue, and then handle the result later when the task finishes. It keeps your app responsive while slow operations such as network calls or file access are running.

Should I learn callbacks, promises, async, or await first?

Start by understanding callbacks, because they are the foundation many libraries still use. Then learn promises and async or await, which are the most common patterns in modern JavaScript, and are easier to reason about for complex flows.

Do async or await replace promises?

No. Async or await is syntax that sits on top of promises. Async functions always return promises under the hood, so learning how promises work will help you understand what async or await is really doing.

Career Services

Personalized career support to help you launch your tech career. Get résumé reviews, mock interviews, and industry insights—so you can showcase your new skills with confidence.