Understanding await, return await and return

Learn the differences between await, return, and return await in JavaScript async functions. Discover best practices for better debugging and error handling.


TL;DR:

Use return await in async functions to obtain better stack traces during debugging; its performance cost is usually negligible.


Asynchronous programming can be complex, especially when working with async functions and the use of await, return await, and return. Today, we'll delve into these concepts, understand their differences, and learn when to use each effectively.

Introduction to Asynchronous Functions

A brief introduction to ensure we're all on the same page:

JavaScript is a single-threaded language, meaning it can execute one task at a time. However, modern applications often need to fetch data from a server or read files from disk, which can take time.

To prevent these time-consuming tasks from blocking the main thread and making the application unresponsive, JavaScript utilizes asynchronous programming.

Asynchronous functions allow you to write code that doesn't wait for a task to complete before moving on to the next one.

Before async and await, we used callbacks and Promises to handle asynchronous operations. While functional, these methods could lead to complex and hard-to-read code structures, often referred to as "callback hell."

JS
// Callback Hell
fetchData(function (error, data) {
  if (error) {
    handleError(error);
  } else {
    processData(data, function (error, processedData) {
      if (error) {
        handleError(error);
      } else {
        // Further processing or final actions
      }
    });
  }
});

// Using Promises
fetchData()
  .then(data => processData(data))
  .catch(error => handleError(error));

    

With the introduction of async and await, JavaScript provides a simpler and more readable way to handle asynchronous code, making it look and behave more like synchronous code.

Understanding async and await

The async Keyword

  • Purpose: Declares a function as asynchronous.
  • Behavior: Every async function returns a Promise. If the function returns a value, the Promise will resolve with that value. If the function throws an error—this is crucial—the Promise is rejected with that error. We'll discuss error handling in more detail later.
JS
async function fetchData() {
  return 'Data fetched';
}

fetchData().then((data) => console.log(data)); // Outputs: Data fetched

async function fetchData() {
  throw Error('Error while fetching');
}

fetchData().catch((error) => console.log(error)); // Outputs: Error: 'Error while fetching'

    

The await Keyword

  • Purpose: Pauses the execution of an async function until a Promise returns a result (resolve or reject).
  • Behavior: Can only be used within async functions. It makes the code wait for the Promise to resolve and returns the resolved value.
JS
...

async function getData() {
  const data = await fetchData();
  console.log(data);
}

getData(); // Outputs: Data fetched

    

By using await, you can write asynchronous code that reads like synchronous code, making it easier to understand and maintain.

A question before we continue: what happens if fetchData returns an error?

await and try/catch

The await keyword plays a crucial role if you want to handle errors in your async functions.

If you use try/catch within an async function and await your results, any Promise rejection will be properly handled, transforming into an exception.

JS
const delayAndFail = (time = 2000) =>
  new Promise((resolve, reject) =>
    setTimeout(() => {
      reject('Something went wrong!');
    }, time),
  );

async function execute() {
  try {
    await delayAndFail();
  } catch (error) {
    console.log(error);
  }
}

execute(); // Outputs: 'Something went wrong!'

    

However, if you don't await your results within a try/catch block, the Promise rejection will not be caught.

JS
...

async function execute() {
  try {
    delayAndFail(); // No awaiting here
  } catch (error) {
    console.log(error);
  }
}

execute(); // Outputs: 'Uncaught (in promise) Something went wrong!'

    

Differences Between await, return await, and return

One of the most challenging parts to understand (at least for me) is when to use these new keywords, specifically return and return await.

There are many articles suggesting different approaches. There are also some general rules, which were eventually discarded...

Let's review this once and for all, because understanding the differences is crucial.

await

  • Usage: Used within an async function to wait for a Promise to resolve before proceeding to the next statement.
  • Effect: Pauses the function's execution until the awaited Promise resolves, then returns the resolved value.
JS
async function execute() {
    await delayAndFail();
}

    

This should be quite clear at this point. But now, return joins the party:

return await

  • Usage: Waits for the Promise to resolve and then returns the resolved value.
  • Effect: Similar to using await and then returning the value.
JS
async function execute() {
    return await delayAndFail();
}

    

return

  • Usage: Returns a Promise immediately, without waiting for it to resolve within the function.
  • Effect: The function exits and returns a pending Promise. The caller can handle the Promise.

Example:

JS
async function execute() {
    return delayAndFail();
}

    

Why the Difference Matters

As you can see, await before return may seem redundant. It's more or less the same, right?

No, it's not. There's a reason why you should almost always use return await instead of just return: to get better error information when handling Promise rejections.

When we add await after return, we are (practically speaking) waiting within the function for the Promise to settle (resolve or reject) before doing anything. If the Promise rejects, then a reference to the function will be present in the stack trace.

JS
async function fail() {
  try {
    await Promise.reject("I'm failing");
  } catch(error) {
    throw new Error(error.message);
  }
}

async function execute() {
  return await fail();
}

execute().catch(error => console.log(error.stack));

/* Outputs this trace
    Error
        at fail (<anonymous>:5:11)
        at async execute (<anonymous>:10:10) */

    

But if we stop awaiting before the return, there's no trace of the execute function.

JS
...

async function execute() {
  return fail();
}

execute().catch(error => console.log(error.stack));

/* Outputs this trace
    Error
        at fail (<anonymous>:5:11) */

    

But Is It Less Efficient?

Yes, using return without await can be slightly more efficient because it doesn't add an extra microtask to the event loop. In my experience, all I can say is this: for most practical scenarios, this supposed performance penalty doesn't even matter. In fact, you might be doing more harm than good.

Conclusion

Using await allows your asynchronous code to read and behave like synchronous code, making it more intuitive.

When choosing between return and return await, remember that return await can provide more complete stack traces, which is vital for debugging. Although there is a minor performance penalty, in most real-world applications, the clarity and better error handling far outweigh the impact.

Juan Andrés Núñez
Juan Andrés Núñez
Senior Frontend Engineer. Vue.js specialist. Speaker. Professional teacher. Stoic.