Thoughts on Await and Try…Catch

Photo by Markus Spiske on Unsplash

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!