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."
// 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.
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.
...
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.
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.
...
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.
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.
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:
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.
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 await
ing before the return
, there's no trace of the execute
function.
...
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.