When writing code, we can expect a wide range of errors to happen. We should handle it by writing our code with that in mind. Even though that’s the case, some errors might slip through the cracks. This article looks into what happens with unhandled errors both in Node.js and in the browser.
Errors in JavaScript
In web development, many situations can result in an error. In general, we could divide them into two categories.
Programmer errors
Programmer errors are caused by bugs in our code. A common example is trying to access a property of undefined.
1 2 |
const response = undefined; console.log(response.data); |
Uncaught TypeError: Cannot read property ‘data’ of undefined
Operational errors
We might run into operational errors even if we wrote our code correctly. They don’t represent bugs but signify that something went wrong. A good example is a user sending a request when logging in, but providing a wrong password.
If you want to learn about the very basics of managing errors, check out Handling errors in JavaScript with try…catch and finally
What happens when an exception is thrown
In JavaScript, an error is an object that we can throw to indicate that something went wrong.
1 2 3 4 5 |
class PostNotFoundException extends Error { constructor(postId) { super(`Post with id ${postId} not found`); } } |
1 2 3 4 5 6 7 |
async getPostById(id) { const post = await this.postsRepository.findOne(id); if (post) { return post; } throw new PostNotFoundException(id); } |
The above snippet is based on the code from the API with NestJS series.
A crucial thing to understand is how JavaScript reacts to exceptions being thrown. First, it stops the execution of the code. Then, it moves up through the call stack of our functions, looking for the closest try...catch block.
1 2 3 4 5 6 |
function divide(firstNumber, secondNumber) { if (secondNumber === 0) { throw Error("You can't divide by zero"); } return firstNumber / secondNumber; } |
1 2 3 4 5 6 |
try { divide(5, 0); console.log('This console.log will not run'); } catch(exception) { console.log('An exception has been caught'); } |
Above, because the divide function threw an error, we can see An exception has been caught in the console.
Uncaught errors in the browser
Let’s look into the MDN documentation for the throw statement:
If no catch block exists among caller functions, the program will terminate.
There is quite a bit more to it, though. When there is an uncaught error in the browser, it aborts processing the current script or a file. When this happens, the window object emits the error event. We can listen to it with addEventListener.
1 2 3 |
window.addEventListener('error', () => { console.log('An uncaught error happened'); }); |
A legacy way to listen for the error event is to use window.onerror.
If we attempt to try the above code out, we might run into an issue. The above event does not fire for errors originated from the Developer Tools console. This applies both to Chrome and Firefox.
Listening to the error event allows us to create a last-resort error handling. For example, we could implement a logic that redirects the user to a page that informs that an unexpected error happened.
React embraces this approach with error boundaries. If you want to know more, check out Bug-proofing our application with Error Boundaries and the Strict Mode
We can access the error event in the listener. Aside from accessing its properties, we can call the preventDefault() method. Doing so causes the error not to be seen in the console.
1 2 3 4 |
window.addEventListener('error', (event) => { // An uncaught error happened. It will not be logged in the console. event.preventDefault(); }); |
If we throw an error in the above listener, it does not emit the error event. Thanks to that, the JavaScript engine avoids infinite recursion.
Uncaught errors in promises
We should also implement error handling for our promises.
1 |
fetch('https://site-does-not-exist.js'); |
When we don’t catch the above promise rejection, we can expect the error to appear in the console.
Uncaught (in promise) TypeError: Failed to fetch
When this happens, the window object emits the unhandledrejection event. We can access the PromiseRejectionEvent and prevent the error from appearing in the console.
1 2 3 4 |
window.addEventListener('unhandledrejection', (event) => { // preventing the rejection from appearing in the console event.preventDefault(); }); |
A legacy way of attach a listener to this event is to use window.onunhandledrejection.
There are some edge cases that we should know about. Let’s consider the code below:
1 2 3 4 |
const request = fetch('https://site-does-not-exist.js'); request.then(() => console.log('Resource fetched')); request.catch(() => console.log('Something went wrong')); |
Something went wrong
Uncaught (in promise) TypeError: Failed to fetch
Above, we can see that error appears to be uncaught even though we’ve used the catch function. To understand this, let’s look closer into the then function. It accepts two arguments: a function called when the promise resolves, and the callback used when the promise is rejected. If we don’t explicitly provide the rejection callback, JavaScript does the following:
1 2 3 4 5 6 7 8 9 10 |
const request = fetch('https://site-does-not-exist.js'); request.then( () => console.log('Resource fetched'), (error) => { throw error; } ); request.catch(() => console.log('Something went wrong')); |
We don’t catch the error thrown in the then callback, and therefore, it appears as uncaught in the console. This issue does not appear if we chain the then and catch functions.
1 2 3 4 5 |
const request = fetch('https://site-does-not-exist.js'); request .then(() => console.log('Resource fetched')) .catch(() => console.log('Something went wrong')); |
Uncaught errors in Node.js
By default, causing an uncaught exception in Node.js results in printing the stack trace and exiting the process with code 1. When a process returns a non-zero code, it signifies an error.
1 |
throw new Error('Oops!'); |
1 2 3 4 5 6 7 8 9 10 11 12 |
/home/marcin/Documents/Projects/errors/index.js:1 throw new Error('Oops!'); ^ Error: Oops! at Object.<anonymous> (/home/marcin/Documents/Projects/errors/index.js:1:7) at Module._compile (internal/modules/cjs/loader.js:1137:30) at Object.Module._extensions..js (internal/modules/cjs/loader.js:1157:10) at Module.load (internal/modules/cjs/loader.js:985:32) at Function.Module._load (internal/modules/cjs/loader.js:878:14) at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12) at internal/main/run_main_module.js:17:47 |
With Node.js, we don’t have the window object. Even though that’s the case, there are some events related to uncaught exceptions.
By listening to the uncaughtException error, we can alter the default behavior. When we do that, Node.js no longer returns code 1 but still terminates the process. We can restore the exit code through the process.exitCode property.
If we want to listen for uncaught exceptions, but we don’t want to alter the default behavior, we can listen to the uncaughtExceptionMonitor event instead.
1 2 3 4 5 6 |
process.on('uncaughtException', (error) => { console.log('Uncaught exception happened', error); process.exitCode = 1; }); throw new Error('Oops!'); |
We can also use process.setUncaughtExceptionCaptureCallback to attach the above listener.
Although we could restart the process within the above listener, Node.js documentation strongly advises against it. The uncaughtException listener is a place to perform the synchronous cleanup of allocated resources, such as files. It is not recommended to resume operation after the uncaughtException event was emitted.
If we want to restart a crashed application more reliably, the official documentation suggests using an external monitor. An example of such is PM2.
Uncaught errors in promises
The topic of unhandled promise rejections in Node.js is somewhat tricky. This is because the default behavior changed across the Node.js versions.
1 2 3 4 5 6 7 |
const fs = require('fs'); const util = require('util'); const readFile = util.promisify(fs.readFile); readFile('./non-existent-file.txt') .then((content) => console.log(content)); |
Above, I use the promisify function to work with promises instead of callbacks. If you want to know more about the file system module, check out Node.js TypeScript #1. Modules, process arguments, basics of the File System
When running the above code with Node.js version previous to 15, we can see the following:
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)
[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.
This means that the process is not terminated. This behavior has been changed in Node.js 15, and the --unhandled-rejections flag is now set to throw by default. Because of that, running into an unhandled promise rejection terminates the process if we don’t listen to the unhandledRejection event.
We can still set --unhandled-rejections to warn if we need the previous behavior.
1 2 3 4 5 6 7 8 9 10 |
const fs = require('fs'); const util = require('util'); const readFile = util.promisify(fs.readFile); process.on('unhandledRejection', (reason, promise) => { console.log('Unhandled Rejection at:', promise, 'reason:', reason); }); readFile('./non-existent-file.txt'); |
Node.js emits the unhandledRejection event every time a promise is rejected without error handling. By listening to it, we can keep track of rejected promises and implement logging, for example.
If we set --unhandled-rejections to strict, our process terminates on uncaught promise rejections even if we listen to the unhandledRejection event.
Summary
In this article, we’ve looked into uncaught errors in JavaScript. It turns out, the browsers and Node.js handle it differently. A topic worth looking into more was unhandled promise rejections. The behavior differs not only between browsers and Node.js but also across different Node.js versions. All this knowledge can come in handy when debugging and building logging functionalities.