How to avoid uncaught async errors in Javascript
Errors in async functions that are not handled show in the console and can cause problems. Let's see how to prevent them
Exceptions, whether sync or async, go up the stack until there is a try..catch
to handle them. If there is no handler on any level, they become uncaught
exceptions. Browsers and NodeJs both show them prominently on the console with an Uncaught Error: <msg>
or a (node:29493) UnhandledPromiseRejectionWarning: Error: <msg>
.
Besides their visibility, in some cases it's sensible to restart an app when there is an uncaught error. It is a best practice to handle all errors in the code and not allow any to bubble up too much.
While uncaught errors work the same in sync and async functions, I've found that it's easier to have an uncaught exception with async functions than with synchronous ones.
In this article, you'll learn about a few cases where async exceptions bubble up and become uncaught errors. You'll learn why they happen and what to do with them.
Async IIFE
For example, let's see the simplest async function, an async IIFE:
(async () => {
throw new Error("err"); // uncaught
})();
First, let's see how a synchronous function works with a try..catch
block:
try {
(() => {
throw new Error("err");
})();
}catch(e) {
console.log(e); // caught
}
The console shows the error is handled by the catch block:
Error: err
Let's change it to an async function:
try {
(async () => {
throw new Error("err"); // uncaught
})();
}catch(e) {
console.log(e)
}
The catch won't run in this case:
Uncaught (in promise) Error: err
Why is that?
In the synchronous case, the error was a sync error, so the sync try..catch
could handle it. More simplified, the program execution never left the
try..catch
block, so any errors were handled by it.
But an async function works differently. The only sync operation there creates a new Promise and the body of the function runs later. The program leaves the
try..catch
by the time the error is thrown so it won't be able to handle it.
With this background information, the solution is straightforward. Since the async function creates a Promise, use its .catch
function to handle any
errors in it:
(async () => {
throw new Error("err");
})().catch((e) => {
console.log(e); // caught
});
Or add a try..catch
inside the async function:
(async () => {
try {
throw new Error("err");
}catch(e) {
console.log(e); // caught
}
})();
Async forEach
Another place where async makes a significant difference on how errors are handled is the async forEach
.
Errors in a sync forEach
are handled by the try..catch
:
try{
[1,2,3].forEach(() => {
throw new Error("err");
});
}catch(e) {
console.log(e); // caught
}
But the simple change of making the iteratee async changes how errors are propagated:
try{
[1,2,3].forEach(async () => {
throw new Error("err");
});
}catch(e) {
console.log(e)
}
This throws 3 uncaught exceptions:
Uncaught (in promise) Error: err
Uncaught (in promise) Error: err
Uncaught (in promise) Error: err
Using async functions with a forEach
is usually a bad idea. Instead, use an async map and await Promise.all
:
try{
await Promise.all([1,2,3].map(async () => {
throw new Error("err");
}));
}catch(e) {
console.log(e); // caught
}
This way, errors are handled similar to the sync version.
Promise chaining
Async functions rely on Promises to perform async operations. Because of this, you can use the .then(onSuccess, onError)
callback with async functions
also.
A common error is to attach the two handlers in one .then
call:
Promise.resolve().then(/*onSuccess*/() => {
throw new Error("err"); // uncaught
}, /*onError*/(e) => {
console.log(e)
});
The problem here is that errors thrown in the onSuccess
function are not handled by the onError
in the same .then
. The solution is to add a
.catch
(equals to .then(undefined, fn)
) after:
Promise.resolve().then(/*onSuccess*/() => {
throw new Error("err");
}).catch(/*onError*/(e) => {
console.log(e); // caught
})
Early init
Another rather common source of uncaught exceptions is to run things in parallel by separating the Promise from the await
. Since only the await
stops
the async function, this structure achieves parallelization.
In this example, p1
starts, then the async function continues to the next line immediately. It starts the second wait
, then stops. When the second
Promise is settled, it moves on to the await p1
that waits for p1
to settle as well. If everything goes well, the two Promises are run in parallel.
But when there are exceptions, the flaws of this structure shows:
const wait = (ms) => new Promise((res) => setTimeout(res, ms));
(async () => {
try{
const p1 = wait(3000).then(() => {throw new Error("err")}); // uncaught
await wait(2000).then(() => {throw new Error("err2")}); // caught
await p1;
}catch(e) {
console.log(e);
}
})();
This produces this log:
Error: err2
Uncaught (in promise) Error: err
The reason behind this is that only the await
throws an exception that the try..catch
can handle, and the first await
is for the second Promise,
after starting the first one. If that is rejected, the program flow jumps over the second await
so that the rejection of the first Promise will be
unhandled.
The solution is to use Promise.all
for parallelization:
await Promise.all([
wait(1000).then(() => {throw new Error("err")}), // p1
wait(2000),
]);
This handles both errors, even though only the first one will be thrown.
Special case
There is an interesting case here. What happens if the first Promise is rejected before the await
for it? For example, p1
will be rejected in 1
second, but the await p1
will be called in 2 seconds:
const wait = (ms) => new Promise((res) => setTimeout(res, ms));
(async () => {
try{
const p1 = wait(1000).then(() => {throw new Error("err")});
await wait(2000);
await p1;
}catch(e) {
console.log(e);
}
})();
Running this in the browser changes the exception from uncaught to caught after 1 second. In NodeJs, the logs preserve what is happening:
(node:29493) UnhandledPromiseRejectionWarning: Error: err
at /tmp/test.js:5:41
(node:29493) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
(node:29493) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
Error: err
at /tmp/test.js:5:41
(node:29493) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 1)
While it technically handles the async exception, I wouldn't call this a good solution. Use the Promise.all
instead.
Event listeners
A common source of unhandled exceptions are in callbacks, such as event listeners:
document.querySelector("button").addEventListener("click", async () => {
throw new Error("err"); // uncaught
});
On the other hand, there is no difference between the sync and the async versions, both produce uncaught exceptions:
document.querySelector("button").addEventListener("click", () => {
throw new Error("err"); // uncaught
})
Use a try..catch
inside the event handler to catch errors.
Promise constructor
The Promise constructor handles synchronous errors and rejects the Promise in that case:
new Promise(() => {
throw new Error("err");
}).catch((e) => {
console.log(e); // caught
});
This is convenient as most errors are automatically propagated in an async function/Promise chain. But it only works for synchronous errors. If there is an exception in a callback, it will be uncaught:
new Promise(() => {
setTimeout(() => {
throw new Error("err"); // uncaught
}, 0);
}).catch((e) => {
console.log(e);
});
The solution is to do one thing in a Promise constructor and use chaining to make more complex operations.
Instead of:
new Promise((res, rej) => {
setTimeout(() => { // 1
connection.query("SELECT ...", (err, results) => { // 2
if (err) {
rej(err);
}else {
const r = transformResult(results); // 3
res(r);
}
});
}, 1000);
});
Separate the 3 operations into 3 different stages:
new Promise((res, rej) => {
setTimeout(res, 1000); // 1
}).then(() => {
connection.query("SELECT ...", (err, results) => { // 2
if (err) {
rej(err);
}else {
res(results);
}
});
}).then((results) => transformResult(results)); // 3
This way, any typos or other synchronous errors will be propagated down the chain and a .catch()
or an await
will handle it.
Conclusion
Uncaught errors can cause many problems besides just showing up in the browser/NodeJs console. They are a signal that error handling is missing in some places and that results in unreliable code.
Keeping in mind that errors can happen mostly anywhere in the code, async errors can have surprising characteristics. In this article, we've discussed some of the potential problems and their solutions.