Thoughts on Await and Try…Catch
Using Async Functions with BAM!
Asynchronous JavaScript is an indispensable tool for developers and software engineers at all levels. This article will cover the basic idea of await
and try...catch
along with some helpful and possibly counterintuitive illustrations. Finally we’ll take a look at a more practical real-world example of asynchronous code in action within the source code of the lightweight serverless framework BAM! (For those who are curious the full white paper for the framework can be found here.)
What are Await and Try…Catch?
Async Functions
Before diving into await
and try...catch
, a brief bit of background on async functions. While it might take a bit of getting used to, async functions are indispensable for building web apps and are a necessary part of any developer’s arsenal.
At their core, async functions (no surprise) are used to run asynchronous code via the JavaScript Event Loop. Their benefit (and perhaps what makes them potentially confusing at first) is that syntactically they look almost like synchronous functions, like so:
async function foo() {
return ‘hello world’;
}function bar() {
return ‘hello world’;
}
Other than the async
keyword these function declarations look alike (also true for function expressions), but their usage and the code they contain can be radically different, as we will soon see.
An async function returns a Promise that is either resolved to the value the function would otherwise return, or rejected with an uncaught exception if something goes wrong during execution. In the example above the async function foo
would, if synchronous, otherwise return ’hello world’
, and so it actually returns a Promise object resolved to that value.
Ultimately async functions provide some nice syntactical sugar over Promises, and provide a much nicer coding experience, especially when running multiple such functions back-to-back.
Await
This is where await
comes in. await
must itself be used within the scope of an async function. It behaves slightly differently depending on the expression that it is passed; either it is passed an object that is already a Promise, or if the value of the expression is not a Promise, it is converted to a Promise that is resolved to that value. Either way we now have some Promise, and await
pauses the execution of the surrounding async function until said Promise is either resolved or rejected. The value of the entire await
expression will be the value of the fulfilled Promise.
This may all seem like a bit to take in at first, so let’s consider some examples:
let a;
let b;
let c;
let d;async function foo() {
// do stuff that might take a while
return 3;
}(async () => {
a = await 1;
b = await Promise.resolve(2);
c = await foo();
d = foo();
})();
What will the values of a
, b
, c
, and d
(eventually) be? It’s probably easy to guess, but it’s important to understand precisely why the values are what they are.
We’ll start with a
. Clearly, 1
is of type Number
, so what will JavaScript do? First, the expression passed to await
has value 1
and is not a Promise, so the expression is first converted to a Promise that is resolved to the value 1
. Then as stated above the value of the entire await
expression will be that of the fulfilled Promise, i.e. 1
.
b
similarly will end up with the value 2
, the difference being that the expression passed to await
is this time around already a fulfilled Promise, in particular with value 2
.
What about c
? In this case we have to recall that the return value of an async function is a Promise resolved to the value that the function would otherwise (if synchronous) return. foo
would return 3
, so the value returned by the async function call will (eventually) be a Promise resolved to 3
, assuming no exception is thrown along the way. Then the value of the await
expression will be that of the returned Promise, so c
will have the value 3
. Of course, depending on the stuff foo
has to execute, it may take some time before the function returns, but this is the beauty of await
! Execution of the outer async function will be paused until the Promise implicit to foo
’s invocation eventually resolves to 3
.
Okay, d
might be a bit of a gotcha; in fact, it’s not even being awaited. This means that the outer async function may return while foo
is still chugging along. Eventually foo
too should finish execution, but remember, d
is not assigned the value of an await
expression, but rather the return value of an async function. The value of d
will be a Promise object, resolved to 3
, not 3
itself!
How did you do? If you got them all, great! If not don’t worry, the key takeaway is that small details are important, and they make a big difference in the long run.
Some More Subtleties
Speaking of small details, let’s take a look at some tricks and subtleties concerning await
.
One technique that came up when our team built BAM! (and happens all the time when developing apps) is delaying an async function for a given amount of time. How would be use await
to delay for, say 2000ms? Remember, await
pauses async function execution until a Promise is resolved, so logically we’ve solved the problem if we can pass to await
a Promise that only resolves after two seconds…
const delay = (time) => {
return new Promise(resolve => setTimeout(resolve, time));
}
…like so.
Now you can delay… to your… heart’s content!
(async () => {
console.log(‘hello…’);
await delay(2000);
console.log(‘ world!’);
})();
To clarify, the Promise returned by delay
only calls resolve
after the interval of time passed to setTimeout
, thus bringing about the pause in execution we are looking to achieve.
On the topic of execution, in what order will the following lines be run?
(async () => {
await console.log(‘foo’);
await console.log(‘bar’);
await console.log(‘baz’);
await console.log(‘qux’);
})();
Unless you thought this was a trick question, you may have expected something like this to be logged:
foo
bar
baz
qux
…and you would be right!
Each line within this async function call is only executed after the previous line; there is a subtle pitfall however, and it arguably stems from the fact that the syntactical sugar provided by await
makes it almost look like synchronous code. What, for example is the difference between the following lines versus what is above?
console.log(‘foo’);
console.log(‘bar’);
console.log(‘baz’);
console.log(‘qux’);
Other than the obvious fact that the former is scoped to an async function, there is another subtle distinction. If you’re thinking ahead, you might be wondering in which cases the code seen above blocks the JavaScript Event Loop.
By way of example, in what order will the following log?
console.log(1);(async () => {
await delay(200);
await console.log(2);
await console.log(3);
await console.log(4);
})();console.log(5);
Hint: it’s not 1
, 2
, 3
, 4
, 5
.
Remember, await
only pauses execution within the scope of its surrounding async function (and it can only be used within an async function in the first place). It does not block the JavaScript Event Loop! This means that while JavaScript waits to log 3
until after 2
is logged, outside the async function scope 5
will be logged as soon as possible. Due to the 200ms delay, we’ll get something like 1
, 5
, 2
, 3
, 4
. The key here is that while await
has the benefit of looking synchronous, it is non-blocking.
Try…Catch
All of the above examples involve a Promise or sequence of Promises that eventually all resolve, which does raise the question: what if one of them is rejected instead? This is where try...catch
comes into play. Without try...catch
let’s say you have something like this:
const foo = async () => {
throw ‘some error’;
};(async () => {
await console.log(1);
await console.log(2);
await foo();
await console.log(3);
await console.log(4);
})();
foo
will throw an error and the corresponding Promise will be rejected; since this rejection is unhandled 3
and 4
will not be logged, and an aptly named UnhandledPromiseRejectionWarning
will be raised. (In fact, at the time of this writing, unhandled promise rejections are deprecated in Node.js; eventually such a rejection will terminate the entire Node.js process.)
So how does one handle rejection? This can be accomplished by using .catch()
on a Promise, but here we will focus on placing a try...catch
block within an async function:
const foo = async () => {
throw ‘some error’;
};(async () => {
try {
await console.log(1);
await console.log(2);
await foo();
await console.log(3);
await console.log(4);
} catch (err) {
console.log(err);
}
})();
This time, when a Promise is rejected within try
, the remainder of the try
block is skipped, and the thrown error is passed to the catch
block, which is then executed. In this example, we should see something like:
1
2
some error
Of course, if no rejection occurs, execution within try
will proceed as normal, and the code within catch
will never run.
In fact, here we are barely scratching the surface of the power try...catch
gives you. For example there are also finally
blocks that will run before any other code within the async function scope but after the try...catch...finally
block. finally
will run whether or not an exception is thrown within try
, and if an exception is thrown, it will even run regardless of whether the rejection is handled! If you’re curious, check out the docs on MDN; this can be quite the rabbit-hole.
BAM!
One tricky detail when working with try...catch
blocks comes up when those blocks are nested; in fact our team encountered this quite often building BAM!, and so we’ll briefly discuss a practical case below.
To provide context, BAM! is a lightweight serverless framework built for deploying small applications to the cloud powered by AWS. Within the source code of BAM! are places where a sequence of asynchronous operations must be performed using Amazon’s Software Development Kit (aws-sdk). Without getting lost in the details, it might come as no surprise that each of these operations is performed within an await
expression, and the entire sequence lies within a try...catch
block. One such sequence looks like this:
// omitted for brevity
try {
const asyncFuncParams = [resourceName, path, roleName, deployDir];
const lambdaData = await bamBam(deployLambda, { asyncFuncParams }); if (lambdaData) await writeLambda(lambdaData, path);
if (deployLambdaOnly) {
await deleteStagingDirForLambda(resourceName, path);
return;
} const {
restApiId,
endpoint,
methodPermissionIds,
} = await deployApi(resourceName, path, httpMethods, stage); const writeParams = [
endpoint,
methodPermissionIds,
resourceName,
restApiId,
path,
]; if (restApiId) await writeApi(…writeParams);
await deleteStagingDirForLambda(resourceName, path);
} catch (err) {
bamError(err);
}
By now the overall picture of a try...catch
block containing a sequence of await
expressions should look familiar. Don’t worry about extraneous details here, just the structure: this is an outer try...catch
block containing an awaited invocation of an async function bamBam
.
bamBam
is our solution to certain latency and throttling concerns; it retries an asynchronous operation in the event of a particular exception, but in the event of a different exception, returns a rejected Promise so that bamError(err)
can be executed. It looks something like:
module.exports = async function bamBam(asyncFunc, {
asyncFuncParams = [],
retryError = 'InvalidParameterValueException',
interval = 3000,
retryCounter = 0,
} = {}) {
const withIncrementedCounter = () => (
{
asyncFuncParams,
retryError,
interval,
retryCounter: retryCounter + 1,
}
); const retry = async (errCode) => {
if (firstTooManyRequestsException(errCode, retryCounter)){
logAwsDelayMsg();
}
const optionalParamsObj = withIncrementedCounter();
await delay(interval);
const data = await bamBam(asyncFunc, optionalParamsObj);
return data;
}; try {
const data = await asyncFunc(...asyncFuncParams);
return data;
} catch (err) {
const errorCode = err.code;
if (errorCode === retryError) {
const data = await retry(errorCode);
return data;
}
throw (err);
}
};
Again, don’t worry about each specific line and what it does; the big picture here is the key. To clarify, if the Promise returned by asyncFunc(...asyncFuncParams)
is resolved, bamBam
itself will return a Promise resolved to its value. If not, depending on the error code, either a retry is attempted (which recurses over bamBam
) or throw (err)
is executed.
Last question about try...catch
: when is bamError(err)
from the previous gist executed and why? Remember, it is in the outer try...catch
block.
The answer? When asyncFunc(...asyncFuncParams)
returns a rejected Promise whose corresponding error code is not retryError
. This causes execution of throw (err)
, which in turn causes the async function bamBam
to return a rejected Promise, thereby passing control to the catch
portion of the outer try...catch
. Note that the error is thrown even within bamBam
’s catch
block. If an error was not thrown, and instead some value were to be returned, bamBam
would simply return a Promise resolved to that value. Think about this for a moment: this would mean bamBam
is returning a resolved Promise, even though asyncFunc(...asyncFuncParams)
returned a rejected Promise! This is the subtle trap with nested try...catch
blocks: it is very easy to accidentally swallow errors, thereby preventing execution of an outer catch
block, especially if one’s codebase is large and contains significant amounts of asynchronous code.
As you can see, it is vital to pay very close attention to when and how a rejected Promise should be handled; it is not universally desirable to swallow errors as close to the rejection as possible. Deliberate and careful coding is necessary to prevent doing this unintentionally. Sometimes you may want to catch
in a certain place only under specific conditions. (If you’re wondering, there actually are such things as conditional catch
clauses but these are considered non-standard and are not recommended.)
All in all, try...catch
provides tremendously useful functionality, but it definitely requires caution and conscientiousness on the part of the developer.
Conclusion
Phew! Hopefully by now you can appreciate some of the intricacies of working with asynchronous code. This is definitely an area that is rife with tiny pitfalls where it is very easy to make mistakes that are both potentially frustrating and difficult to spot. However, it can be tremendously rewarding to finally grasp these concepts, and it is well worth the effort to learn. Happy coding!