Exploring Async/Await in JavaScript with Examples

The author of the article explains the use of Async/Await in JavaScript through examples. Overall, Async/Await is a convenient way to write asynchronous code. Before this feature, such code was written using callbacks and promises. The author of the original article reveals the advantages of Async/Await by examining various examples.

Reminder: for all readers of 'Habr' - a discount of 10,000 rubles when enrolling in any Skillbox course with the promo code 'Habr'.

Skillbox recommends: Online educational course Java Developer.

Callback

A callback is a function whose execution is deferred for an indefinite time. Previously, callbacks were used in code sections where the result could not be obtained immediately.

Here's an example of asynchronous file reading in Node.js:

fs.readFile(__filename, 'utf-8', (err, data) => {
  if (err) {
    throw err;
  }
  console.log(data);
});

Problems arise when multiple asynchronous operations need to be executed simultaneously. Let's imagine a scenario: a request is made to the database for user Arfat, and it is necessary to read his profile_img_url field and load an image from the server someserver.com.
After loading, we convert the image to another format, for example, from PNG to JPEG. If the conversion is successful, an email is sent to the user. Additionally, the event information is logged in the transformations.log file with the date indicated.

It is important to note the nesting of callbacks and the large number of }) in the final part of the code. This is called Callback Hell or Pyramid of Doom.

The drawbacks of this approach are evident:

  • This code is difficult to read.
  • It is also hard to handle errors, which often leads to a decline in code quality.

To solve this problem, promises were added to JavaScript. They allow replacing deep nesting of callbacks with the .then keyword.

A positive aspect of promises is that they make the code much easier to read, from top to bottom rather than from left to right. However, promises also have their own issues:

  • You need to add a lot of .then.
  • Instead of try/catch, .catch is used to handle all errors.
  • Working with multiple promises within a single cycle is not always convenient; in some cases, it complicates the code.

Here's a task that will illustrate the significance of the last point.

Let’s assume there is a for loop that outputs a sequence of numbers from 0 to 10 with a random interval (0–n seconds). Using promises, we need to modify this loop so that the numbers are output in the order from 0 to 10. So, if it takes 6 seconds to output zero and 2 seconds to output one, zero should be output first and only then should the output of one start.

And of course, for solving this task we do not use Async/Await or .sort. An example solution is provided at the end.

Async Functions

The addition of async functions in ES2017 (ES8) simplified working with promises. I would note that async functions work 'on top of' promises. These functions are not a qualitatively different concept. Async functions were designed as an alternative to code that uses promises.

Async/Await allows us to organize asynchronous code in a synchronous style.

Thus, knowledge of promises facilitates understanding of the principles of Async/Await.

Syntax

In a typical situation, it consists of two keywords: async and await. The first word turns a function into asynchronous. In such functions, the use of await is allowed. In any other case, using this function will result in an error.

// With function declaration
 
async function myFn() {
  // await ...
}
 
// With arrow function
 
const myFn = async () => {
  // await ...
}
 
function myFn() {
  // await fn(); (Syntax Error since no async)
}
 

Async is inserted at the very beginning of the function declaration, and in the case of using an arrow function, it comes between the '=' sign and the parentheses.

These functions can be placed in an object as methods or used in a class declaration.

// As an object's method
 
const obj = {
  async getName() {
    return fetch('https://www.example.com');
  }
}
 
// In a class
 
class Obj {
  async getResource() {
    return fetch('https://www.example.com');
  }
}

NB! It is worth remembering that class constructors and getters/setters cannot be asynchronous.

Semantics and Execution Rules

Async functions are, in principle, similar to standard JS functions, but there are exceptions.

Thus, async functions always return promises:

async function fn() {
  return 'hello';
}
fn().then(console.log)
// hello

In particular, fn returns the string hello. And since this is an asynchronous function, the string value is wrapped in a promise using the constructor.

Here is an alternative construction without Async:

function fn() {
  return Promise.resolve('hello');
}

fn().then(console.log);
// hello

In this case, returning a promise is done 'manually'. An asynchronous function is always wrapped in a new promise.

In cases where the return value is a primitive, the async function returns the value by wrapping it in a promise. If the return value is a promise object, its resolution is returned in a new promise.

const p = Promise.resolve('hello')
p instanceof Promise;
// true

Promise.resolve(p) === p;
// true
 

But what happens if there is an error inside the async function?

async function foo() {
  throw Error('bar');
}

foo().catch(console.log);

If it is not handled, foo() will return a promise with a reject. In this situation, instead of Promise.resolve, Promise.reject will return the error.

Async functions always yield a promise, regardless of what is returned.

Async functions pause at each await.

Await affects expressions. If the expression is a promise, the async function pauses until the promise is fulfilled. If the expression is not a promise, it is converted to a promise via Promise.resolve and then completes.

// utility function to cause delay
// and get random value
 
const delayAndGetRandom = (ms) => {
  return new Promise(resolve => setTimeout(
    () => {
      const val = Math.trunc(Math.random() * 100);
      resolve(val);
    }, ms
  ));
};
 
async function fn() {
  const a = await 9;
  const b = await delayAndGetRandom(1000);
  const c = await 5;
  await delayAndGetRandom(1000);
 
  return a + b * c;
}
 
// Execute fn
fn().then(console.log);

Here is a description of how the fn function operates.

  • After being called, the first line gets converted from const a = await 9; to const a = await Promise.resolve(9);.
  • After using Await, the function execution pauses until a gets its value (in this case, 9).
  • delayAndGetRandom(1000) pauses the execution of the fn function until it completes (after 1 second). This effectively halts the fn function for 1 second.
  • delayAndGetRandom(1000) returns a random value via resolve, which is then assigned to variable b.
  • And the case with variable c is similar to that of variable a. After this, everything pauses for a second, but now delayAndGetRandom(1000) returns nothing, as that is not required.
  • As a result, values are calculated using the formula a + b * c. The result is then wrapped in a promise using Promise.resolve and returned by the function.

These pauses may resemble generators in ES6, but there are specific reasons for this.

Solving the problem

Now let's look at the solution to the problem mentioned above.

The finishMyTask function uses Await to wait for the results of operations such as queryDatabase, sendEmail, logTaskInFile, and others. If we compare this solution to one that uses promises, the similarity becomes obvious. However, the version with Async/Await significantly simplifies all the syntactical complexities. In this case, there are not many callbacks and chains like .then/.catch.

Here’s a solution for outputting numbers, with two variations available.

const wait = (i, ms) => new Promise(resolve => setTimeout(() => resolve(i), ms));
 
// Implementation One (Using for-loop)
const printNumbers = () => new Promise((resolve) => {
  let pr = Promise.resolve(0);
  for (let i = 1; i <= 10; i += 1) {
    pr = pr.then((val) => {
      console.log(val);
      return wait(i, Math.random() * 1000);
    });
  }
  resolve(pr);
});
 
// Implementation Two (Using Recursion)
 
const printNumbersRecursive = () => {
  return Promise.resolve(0).then(function processNextPromise(i) {
 
    if (i === 10) {
      return undefined;
    }
 
    return wait(i, Math.random() * 1000).then((val) => {
      console.log(val);
      return processNextPromise(i + 1);
    });
  });
};

And here’s a solution using async functions.

async function printNumbersUsingAsync() {
  for (let i = 0; i < 10; i++) {
    await wait(i, Math.random() * 1000);
    console.log(i);
  }
}

Error Handling

Unhandled errors are wrapped in a rejected promise. However, in async functions, the try/catch structure can be used for synchronous error handling.

async function canRejectOrReturn() {
  // wait one second
  await new Promise(res => setTimeout(res, 1000));

  // Reject with ~50% probability
  if (Math.random() > 0.5) {
    throw new Error('Sorry, number too big.')
  }

  return 'perfect number';
}

canRejectOrReturn() is an async function that either succeeds ('perfect number') or fails with an error ('Sorry, number too big').

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

Since the canRejectOrReturn function is awaited in the example above, its own failure will trigger the catch block. As a result, the foo function will complete either with undefined (when nothing is returned in the try block) or with error caught. Consequently, this function will not fail, as the try/catch will handle the logic of the foo function itself.

Here’s another example:

async function foo() {
  try {
    return canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

It’s important to note that in the example of foo, canRejectOrReturn is returned. Foo in this case completes either with the perfect number or throws an error ("Sorry, number too big"). The catch block will never be executed.

The problem is that foo returns a promise passed from canRejectOrReturn. Therefore, the resolution of the foo function becomes the resolution of canRejectOrReturn. In this case, the code consists of just two lines:

try {
    const promise = canRejectOrReturn();
    return promise;
}

Now, what happens if we use await and return together:

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

In the code above, foo will successfully complete with both a perfect number and an error caught. There will be no failures here. However, foo will end with canRejectOrReturn, not with undefined. Let's verify this by removing the line return await canRejectOrReturn():

try {
    const value = await canRejectOrReturn();
    return value;
}
// …

Common mistakes and pitfalls

In some cases, using Async/Await can lead to errors.

Forgotten await

This happens quite often — the keyword await is forgotten before the promise:

async function foo() {
  try {
    canRejectOrReturn();
  } catch (e) {
    return 'caught';
  }
}

The code, as you can see, has neither await nor return. Therefore, foo always ends with undefined without a 1-second delay. However, the promise will execute. If it produces an error or a reject, then in that case, UnhandledPromiseRejectionWarning will be triggered.

Async functions in callbacks

Async functions are often used in .map or .filter as callbacks. For example, the function fetchPublicReposCount(username) returns the number of public repositories on GitHub. Let's say there are three users whose metrics we need. Here’s the code for this task:

const url = 'https://api.github.com/users';
 
// Utility function to fetch repo counts
const fetchPublicReposCount = async (username) => {
  const response = await fetch(`${url}/${username}`);
  const json = await response.json();
  return json['public_repos'];
}

We need the accounts ArfatSalman, octocat, norvig. In this case, we execute:

const users = [
  'ArfatSalman',
  'octocat',
  'norvig'
];
 
const counts = users.map(async username => {
  const count = await fetchPublicReposCount(username);
  return count;
});

It is worth noting the await in the callback of .map. Here counts is an array of promises, and .map provides an anonymous callback for each specified user.

Excessive sequential use of await

As an example, let's take this code:

async function fetchAllCounts(users) {
  const counts = [];
  for (let i = 0; i < users.length; i++) {
    const username = users[i];
    const count = await fetchPublicReposCount(username);
    counts.push(count);
  }
  return counts;
}

Here, the variable count stores the repository number, which is then added to the counts array. The problem with this code is that while waiting for the data from the first user to arrive from the server, all subsequent users will be in wait mode. Thus, only one user is processed at a time.

For example, if it takes about 300 ms to process one user, then for all users, it already amounts to one second; the time spent is linearly dependent on the number of users. However, since fetching the number of repos does not depend on one another, the processes can be parallelized. To achieve this, work with .map and Promise.all is needed:

async function fetchAllCounts(users) {
  const promises = users.map(async username => {
    const count = await fetchPublicReposCount(username);
    return count;
  });
  return Promise.all(promises);
}

Promise.all takes an array of promises as input and returns a promise. It resolves after all the promises in the array are fulfilled or at the first rejection. It's possible that not all of them will start simultaneously; to ensure simultaneous execution, p-map can be used.

Conclusion

Async functions are becoming increasingly important for development. To use async functions adaptively, it's worth leveraging Async Iterators. A JavaScript developer should have a good understanding of this.

Skillbox recommends:

Source: habr.com

Buy reliable website hosting with DDoS protection, VPS VDS servers 🔥 Buy reliable website hosting with DDoS protection, VPS VDS servers | ProHoster