JavaScript Async Code - Callbacks, Promises, Async/Await

Photo of Michał Sobczak

Michał Sobczak

Updated Apr 16, 2024 • 13 min read

Introduction - callbacks

At the end of the last part of this series we tackled callbacks and some concerns of using them. This one will mostly cover issues they can cause and modern methods of handling asynchronous code.

Callbacks are the fundamental element of writing asynchronous JS code. However, they have many flaws and programmers need more reliable and clear solutions. Whenever you create a piece of code that does something asynchronously (e.g. fetches data from a server, reads a file from disk etc.), you probably don’t want to wait until the process ends, preferably leaving code instructions that will be called later - those are called callbacks (as “call me back” when you finish executing).

The first problem is that code written using callbacks might not be intuitive. Also, nested callbacks with more and more indentations don’t help. Quoting Kyle Simpson from his You Don't Know JS book: “(...) our brains plan things out in sequential, blocking, single-threaded semantic ways, but callbacks express asynchronous flow in a rather nonlinear, non-sequential way (...)" programmers should seek the easiest, sequential ways to describe asynchronous code.

The code below is just a brief presentation of how things can get complicated real quick - and this is without any additional code for actual features.


  document.addEventListener('click', function(res1) {
    // more code…
    helperFunction1(res1);
setTimeout(function(res2) { // more code… helperFunction2(res2);
ajax('https://test-url.com/resource', function(res3) { helperFunction3(res3);
if (res3 === 200) { ajax('https://test-url.com/additional-resource', function(res4) { console.log(res4); // instructions what to do with resource }); } else { tracker.analytics(res3, function() { // display info about response }); } });
helperFunction4(); }, 1000); helperFunction5(); });

A better approach would be to name the anonymous callback functions and extract them to separate blocks, but it would still force the developer to traverse through the file every time to find info about the next instructions. Beside this, less experienced developers might need more time to understand the order of execution together with all the helperFunctions. On top of that, functions using callbacks are, as a rule, asynchronous - they can fail unexpectedly and it might be difficult to think of all the potential failure scenarios. This means additional lines to handle errors for every callback and all the different possibilities, leading to more complicated code.

Even dealing with the aforementioned cases isn't always enough. The crucial flaw of callbacks is the inversion of control in the sense that your main program gives it to the external function, most often delivered by a third party subject.

analytics.trackTransfer(data, function updateBalance() {
 // very sensitive code
})

In this short example, we don’t always know what exactly the method trackTransfer will do and yet it takes over control from the main program and decides when to use our provided callback. In the worst case scenario it could even cause multiple callback executions resulting in e.g. multiple unwanted money transfers.

Promises

The problems described above have been annoying developers for years. The main riddle to solve was how to prevent the inversion of control while still enabling developers to write code that looks as close to synchronous as possible. It was crucial to improve the workflow with async operations for the sake of future development.

ES6 came with so-called Promises. This approach is based on “reverting” the inversion of control so that we can wait for the result of an asynchronous operation and then - within our main program - decide what to do with the data.

A promise is basically a placeholder for the future outcome of asynchronous code. When started, it changes its state to Pending, informing you that an operation is in progress. A promise can be in two other states - Fulfilled and Rejected - whenever the job performed by it has ended (either with success or failure). A promise in either of these two states is Settled. Keep in mind that promises can’t be “unsettled” after changing their status from pending, nor can they be cancelled. Then, the developer can decide what to do with the outcome of the operation (see how control is now within the main program?).

Finding out about a new, cool technology is always exciting and the vast majority of developers will probably agree that they want to jump into it straightaway, as it’s the most convenient and fun way to learn. Let’s get started on using promises then.

Using promises

In the following examples we use the Fetch API, which provides the fetch method to perform asynchronous requests.

fetch("https://api.punkapi.com/v2/beers?per_page=5")
Here, we call the fetch method that will perform a request to an external API. This won’t give us the data right away, but just a pending promise. After it’s resolved to a value we can read it using the .then() method from the Promise API - which will take a callback function(s), but also return a promise.
fetch("https://api.punkapi.com/v2/beers?per_page=5")
  .then((res) => res.json())
  .then((data) => console.log(data));

If it is successful, fetch operation will come back with a resolved promise containing a response object (res in the code above). To retrieve the data we will need to use the .json() method of that object. As then() returns a promise, we can attach another one to wait for the previous one to be resolved - this is called promise chaining. It will get the returned value as a parameter from the previous call, so we can just manipulate our data here, in this case just outputting it to the console.

Now, there are a few places here where errors can happen and the code has to be ready to handle them.

fetch("https://api.punkabi.com/v2/beerZ?per_page=5")
  .then((res) => res.json())
  .then((data) => console.log(data));

Problem #1: Wrong domain name - causing fetch to fail, in consequence returning rejected a promise.

Solution: The .then() method accepts an additional function as a parameter, which will handle failure cases.

fetch("https://api.punkabi.com/v2/beers?per_page=5").then(
  // onFulfillment handler - gets executed when promise is
  // resolved
  (res) => {
// remember to return the value to continue the promise chain return res.json().then((data) => console.log(data)); },
// onRejected handler - gets executed when promise is rejected
(err) => {
console.error(err);
}
);

As the .then() method can only execute one of the handlers, we have both cases covered. But what if the failure happens in the body of the fulfillment handler?

Problem #2: Promise rejection within the chain e.g. in the fulfillment handler. As only one handler can be executed in the first .then() block, it will give us a rejected promise. This will cause an error which is pretty obvious, therefore we have an error that is not handled.

fetch('https://api.punkapi.com/v2/beers?per_page=5').then(
   (res) => Promise.reject('Data not found!').then((data) => console.log(data)),
   (error) => console.warn('The error: ', error),
);

As we can see there is no “The error: ” preceding the actual error, which means our rejection handler was never called.

Solution: Provide an additional .then() call which will only listen for rejected promises.

fetch('https://api.punkapi.com/v2/beers?per_page=5')
   .then((res) => Promise.reject('Data not found!')
   .then(undefined, (err) => console.warn('The error: ', err));

Now we can see the proper error message displayed. Actually this is used so often it has its own method name called .catch(), which will only take the error handler as a parameter.

.catch((err) => console.warn('The error: ', err));

Problem #3: If fetch() points to an endpoint that doesn’t exist, the response object will be returned, but it will contain no valuable data to process, so using it for normal purposes will be impossible this way.

fetch('https://api.punkapi.com/v2/beerZ?per_page=5')
   .then((res) => res.json()
.then((data) => doSomeAction(data))) .catch((err) => console.warn('The error: ', err));

Solution: Handle the error response when it comes back.

fetch('https://api.punkapi.com/v2/beers?per_page=5')
   .then((res) => {
     if (!res.ok) throw new Error('Data not fetched!');
     return res.json();
   })
   .then((data) => doSomeAction(data))
   .catch((err) => console.warn(err));

Instead of checking res.ok we can also test for response status and decide what to do on specific ones. This way, if the response is not not satisfying, a thrown error will cause the promise to be rejected, and therefore be caught by the catch() statement and handled there.

There is also the useful .finally() method, which can be used for cleanup after a promise is settled, for example by setting the loading flag to false when request handling is done.

let isLoading = true;
fetch('https://api.punkapi.com/v2/beers?per_page=5')
   .then((res) => {
     if (!res.ok) throw new Error('Data not fetched!');
     return res.json();
   })
   .then((data) => doSomeAction(data))
   .catch((err) => console.warn(err))
   .finally(() => (isLoading = false));

The code reads like if it was synchronous, right? We can clearly state what will happen after every line of code and control the flow accordingly.

Async/Await

ES7 introduced some syntactic sugar for the promises themselves, so that code can look even more straightforward. Using this syntax is simple. The async keyword marks a function as one returning promise. If the function by definition doesn’t return a promise, preceding it with async causes the returned value to be wrapped by a resolved promise automatically.

const returnValue = async () => 'promisified value';

Trying to log this function’s return value we will see that there is a resolved promise with the function’s return value:

console.log(returnValue()); // Promise {<resolved>: "promisified value"}

This value can be accessed using the promise approach by calling .then():

returnValue().then((res) => console.log(res)); // "promisified value"

or using the newer syntax with the await keyword, which will cause the code to wait for when the promise is settled:

const response = await returnValue();
console.log(response); // "promisified value"

Let’s quickly take a look at how the promise example from above would look in ES7. The await keyword can be used only in async functions, so we will wrap it in with one. Here, to have error handling logic we need to manually use a try/catch block.

const loadData = async () => {
   let isLoading = true;
   try {
     const res = await fetch('https://api.punkapi.com/v2/beers?per_page=5');
     if (!res.ok) throw new Error('Data not fetched!');
     const data = await res.json();
     doSomeAction(data);
   } catch (err) {
     console.warn(err);
   } finally {
     isLoading = false;
   }
};

Now the code barely looks asynchronous which should be appreciated by devs trying to figure out what is going on in your code. However, it always depends on personal preference whether to use the promise or async/await syntax - both are quite common in the dev community.

Still better than the callback madness, right?

See you around!
Michał

Photo of Michał Sobczak

More posts by this author

Michał Sobczak

Michał is a positive person who always finds a bright side to any situation. From a very young age,...
Lost with AI?  Get the most important news weekly, straight to your inbox, curated by our CEO  Subscribe to AI'm Informed

Read more on our Blog

Check out the knowledge base collected and distilled by experienced professionals.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business