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

Author's image
Tamás Sallai
8 mins

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.

July 20, 2021
In this article