Why asynchronous exceptions are uncatchable with callbacks

You have a block of code that throws an exception. You have tried putting it inside a try..catch block, but it still does not get caught. You have been told the error is thrown asynchronously, but you are not sure what is exactly going on. You would like to understand why your code doesn't catch it.

1 try {
2     // perform work asynchronously that
3     // throws error at some point
4 } catch (e) {
5     // catch error.. doesn't work
6 }

What is asynchronous code

An asynchronous function is a function that performs an operation in the background. It leverages services provided by the JavaScript host and the operating system. What is visible in JavaScript to such call is 1) the initial setting up and initialization of the call and 2) the point when the callback is called and the operation is complete.

Throwing an error asynchronously means that an error is thrown in the JavaScript code of an asynchronously executed callback.

Throwing exception asynchronously

The following code reproduces the example.

1 try {
2     setImmediate(() => {
3         throw new Error();
4     });
5 } catch (e) {
6     // catch error.. doesn't work
7 }

Here a try.. catch block is used to wrap a call to setImmediate(). It is a function that operates asynchronously and schedules the argument callback to be called in the near future, as soon as other operations have finished. There are no other statements to execute, and the control is passed back to one level up — to the Node.js event loop.

Pseudocode of event loop

In order to better understand what is taking place one level up from the user code, let's write the event loop in JavaScript pseudocode. Pseudocode is something that describes a concept at a higher level, possibly omitting implementation details. In real life, the event loop in Node.js is written in C.

The event loop is an infinite while loop that is entered as the last thing when Node.js process is started. Its responsibility is to repeatedly take events and fire any listeners registered for them one at a time. Listeners are called sequentially - one at a time.

It can be described by combining two API functions in a while loop.

1 var event;
2 while (event = getNextEvent()) {
3     getListeners(event).forEach((listener) => {
4         listener(event);
5     });
6 }

Here getNextEvent() returns the next event. If there are no events available, it will block until new events occur. The getListeners() function returns an array of listeners that were waiting for the given event.

Run in two passes

Let's go back to the example. We can see there's the initial user code that makes the setImmediate()-call. The anonymous function passed as argument is not executed at this point, and it is only merely passed as an argument like a number, or a string would be passed.

1 try {
2     setImmediate(() => {
3         throw new Error();
4     });
5 } catch (e) {
6     // catch error.. doesn't work
7 }

We can think the highlighted code is executed in one pass. After the pass has finished, the control is passed one level up. Then, after other operations have finished, the setImmediate() contract is fulfilled, and the following section is executed.

1 try {
2     setImmediate(() => {
3         throw new Error();
4     });
5 } catch (e) {
6     // catch error.. doesn't work
7 }

If we combine the highlights, we can see the entire file actually contains pieces that are executed at different times.

1 try {
2     setImmediate(() => {
3         throw new Error();
4     });
5 } catch (e) {
6     // catch error.. doesn't work
7 }

Visualize executed passes by including surrounding event loop code

Let's combine this with the event loop pseudocode. We will replace the listener calling in the pseudocode with the actual code that will be executed. The first pass looks like

1 while (event = getNextEvent()) {
2     getListeners(event).forEach((listener) => {
3         try {
4             setImmediate(...);
5         } catch (e) {
6             // catch error.. doesn't work
7         }
8     });
9 }

And the second part, after the "immediately after other operations have finished" event has occurred, looks like

1 while (event = getNextEvent()) {
2     getListeners(event).forEach((listener) => {
3         throw new Error();
4     });
5 }

From the second part, it can be seen that the original catch block that was intended to be there is not present. When the code is executed asynchronously, the original synchronous catch block is not present. In this case, the exception will propagate all the way up to the Node.js internals and will cause the program to exit prematurely.

Catch block is not present when asynchronous callback is executed

An asynchronous exception is uncatchable because the intended catch block is not present when the asynchronous callback is executed. Instead, the exception will propagate all the way and terminate the program.

Related articles

Node doesn't wait for your database call to finish?

Node doesn't wait for your database call to finish?

Learn how asynchronous calls work and make your app run as you intended. Get short email course on asynchronicity and two chapters from Finish Your Node App.

Take Free Course