Skip to content

A Visual Guide to Refactoring Callback Functions to Promises & Async/await

5 min read

Are you constantly struggling to keep your code at least halfway understandable while having deeply nested calls everywhere?

Callback trees a million deep are distressing.

Perhaps you're still not comfortable with async/await and you're stuck using promises.

But what if you understood how async/await works, what would you accomplish? A successful job interview, recognition for your skills, or maybe a promotion?

Imagine working with code that's easy to understand and change, how would that change how you feel about your work?

By learning the simple approach of identifying and isolating the individual parts involved in asynchronous code flow, you will avoid introducing bugs in the refactoring process.

You'll learn a new skill that will give you the confidence to turn callback hells into async joys.

A primer on Node.js callback convention

Callbacks can be either synchronous or asynchronous. When talking about asynchronous callbacks in Node.js, the following two points are true in most cases:

  1. The callback function is always the last argument passed to an asynchronous function, preceded by other arguments (if any):
// The callback function is the last argument to an asynchronous function
asyncFunction(...params, callback);
  1. If an asynchronous operation fails, the error object will be the first argument passed to the callback function. In case of a success, the error argument will be null followed by 0, 1 or more return values:
// An error-first callback function
callback(error, ...results) {
  if (error) {
    // Handle error
    return;
  }

  // Do something with the result...
}

This error-first callback style has become a standard in the Node.js community. It's a familiar pattern that makes working with asynchronous code easier.

Parts of asynchronous code flow

Asynchronous code can be broken into a few different parts. Identifying and isolating these individual parts before refactoring is key to not breaking your code in the process.

The five parts are:

  • Function execution (with arguments, if any)
  • Error object
  • Return value(s)
  • Error handling
  • Using return value(s)

Throughout this article, we'll use reading the contents of a file in Node.js as an example. We'll start with the callback approach, then refactor that into a promise, and lastly refactor to use async/await.

Here's an exercise for you — before reading on, try to identify and isolate all five parts in the following code snippet.

Using fs.readFile to read a file and passing a callback function with two arguments: error and data. If the error exists it is handled first and otherwise the return value is used.
Reading a file in Node.js using a callback function

Go ahead, I'll wait.

.
.
.
.
.
.
.
.
.
.

Did you correctly identify all parts involved in asynchronous code flow? Compare your answer with the image below:

Reading a file in Node.js with fs.readFile and a callback function. Five parts of asynchronous code flow are circled: function execution, error object, return value, error handling and using the return value.
Individual parts of asynchronous code flow

Refactoring callback functions to promises

Once you've identified and isolated the individual parts, you're ready to refactor the callback function to use its promise counterpart.

While refactoring, it's important to remember to not change anything internal to the individual parts.

Refactoring a callback function to a promise is done by moving the parts as a whole and putting them together in a different way.

The following animation explains this process visually:

The parts that are handling the error and using the return value are short one-liners for example purposes. In your situation, they will likely be much bigger, but the principle remains the same — the parts should be moved as a whole unit without modifying them or breaking them apart.

A noticeable difference between callback functions and promises is that error handling (failure) is separated from using the return value (success). This visual separation is a better representation of the two diverging code paths and is, therefore, is easier to work with.

Refactoring promises to async/await

Refactoring callback functions straight to async/await involves multiple steps and will take some practice before you get the hang of it.

It might be easier and less error-prone to add an intermediary step to the refactoring process. First, refactor the callback function to a promise, and only then refactor the promise to use async/await.

This is how the transition from a promise to async/await looks visually:

Notice how much less movement there is compared to the previous animation that went from a callback function to a promise. Because the success and failure parts are separately kept, refactoring a promise to async/await is mostly about changing the syntax.

Conclusion

It takes a lot of practice before you're able to effortlessly refactor callback functions into promises & async/await.

By first identifying and isolating the individual parts involved in asynchronous code flow, you're less likely to break your application while refactoring.

Now it's your turn to get rid of nightmare-inducing legacy code and do a long-awaited (pun not intended) cleanup. The codebase will be easier to read, maintain, and most importantly, a joy to work with. ✨

In case you need to brush up your understanding of async/await, go ahead and give the article a read.

Turn deeply nested callback trees into easy-to-read asynchronous code

Learn how to turn unmaintainable code into code that is easy to read and change with a FREE 5-day email course.

You'll get the Refactoring Callbacks Guide that has visual explanations of how to convert nested callbacks to async/await. Using a simple yet effective 5-step approach, you'll gain the confidence to refactor deeply nested callback hells without introducing new bugs.

Moreover, with 30+ real-world exercises you'll transfer knowledge into a practical skill that will greatly benefit your career.

Get Lesson 1 now 👇🏼

You'll also get tips on building scalable Node.js applications about twice a month. I respect your email privacy. Unsubscribe any time.

You might also like

Run Concurrent Tasks With a Limit Using Pure JavaScript

Run asynchronous tasks with a concurrency limit without having to rely on an external library.
Read article

3 Reasons Why Async/Await Is Better Than Chaining Promises

Why use async/await when you can use promises instead? Here's why async/await excels at asynchronous JavaScript code.
Read article

Using Callbacks With Async/Await

How do you make a callback-based asynchronous function work nicely with async/await? You promisify it.
Read article