Node.js Error Handling Best Practices: Ship With Confidence

By: Lou
  |  February 28, 2024
Node.js Error Handling Best Practices: Ship With Confidence

Node.js error handling isn’t a walk in the park. When deploying applications into production, we want to know that all code has been tested for all possible complex scenarios.

We can, however, avoid issues by preparing our code to properly handle errors. To do so, we need to understand:

Today we’re going to take you through the above items and give you a tour of JavaScript’s main quirks for better insight and understanding. Then, we’ll discuss best practices and common stumbling blocks that developers often struggle with when dealing with Node.js error handling.

So let’s get to it.

Node.JS Error Handling: Why Bother?

First let’s address the big question: why bother handling errors at all?

Imagine you’re using Node.js to build a RESTful web API. You have users who are sending requests for data from your server. All good so far, right?

Let’s look at an example. Imagine you’re using Node.js to build a RESTful web API. You have users who are sending requests for data from your server. All good so far, right?

With anyone in the world throwing requests at our program, it’s only a matter of time before values get into our program that we didn’t expect. And when we get these values, effectively responding to our users means being as descriptive as possible about the error. If we don’t have proper Node.js error handling, it’s likely we’ll throw a generic error message. So the user will see the error message “Unable to complete request.”

This is not very useful.

Furthermore, it’s not just the user experience that dictates why we should be savvy with our Node.js error handling. If we want our program to be secure, resilient, high-performing and bug-free, Node.js error handling is a must.

Types of Errors in NodeJS

In the context of Node.js, errors are generally classified into two categories: operational errors and programmer errors, which are also known as functional errors.

Operational errors in Node.js refer to runtime errors when an application encounters an unhandled exception or another code issue

Operational Errors

Operational errors in Node.js refer to runtime errors when an application encounters an unhandled exception or another code issue. These errors can be challenging to diagnose, as memory leaks, infinite loops, incorrect system configurations, or a combination of these issues often cause them.

The most common way of dealing with operational errors is by implementing a robust error-handling strategy designed to capture any error and provide insight into what went wrong so developers can address it quickly and efficiently.

Functional Errors

Functional errors refer to bugs in the application code that prevent it from functioning as expected. These types of errors usually require more effort to identify than operational ones since they manifest themselves in unexpected ways or only at certain times during the application’s lifecycle. Examples of functional errors include incorrect logic or calculations, missed use cases, and other miscellaneous coding mistakes such as typos or misconfigurations.

Identifying functional errors requires developers to look deeper into their codebase and use debugging tools like breakpoints and stack traces to pinpoint precisely where the issue lies within their application.

Error Object

The first thing to know about Node.js error handling is the error object.

You might have seen some code that looks like this:

throw new Error('database failed to connect');

That may sound pretty overwhelming.

For simplicity, let’s break it down. Two distinct things are happening here: the error object is being created and is being thrown. Let’s start by looking at the error object and how it works and get back to the throw keyword a little later.

Despite their scary exterior, error objects are pretty straightforward. The error object is an implementation of a constructor function that uses a set of instructions (the arguments and constructor body itself) to create an object. That’s it. The built-in error constructor is simply a unified way of creating an error object.

What are error objects, anyway? Why do they need to be uniform?

These are important questions, so let’s get to them.

Anatomy of an error object

The first argument for a native error object is its description. The description is the human-readable string of your error object. It’s what pops up in your console when something goes awry.

Second, error objects also have a name property, which is the computer-readable part of the object. When you use the native error object, the name property defaults to the generic “Error.” However, you can also create your own. The best way to do this is by extending the native error object like so:

class FancyError extends Error {
    constructor(args){
        super(args);
        this.name = "FancyError"
    }
}
console.log(new Error('A standard error'))
// { [Error: A standard error] }
console.log(new FancyError('An augmented error'))
// { [Your fancy error: An augmented error] name: 'FancyError' }

We mentioned earlier that we want our error objects to be uniform. That’s because when we throw an error, it helps to have consistency in the thrown object. If we have consistency in our objects, error handling is easier (more on this later).

Now let’s discuss throw, our next piece in the puzzle.

Uh-oh, an error – throw it!

Creating an error object is not the end of the story and only metaphorically primes our error for sending. How we hit send on our error is by throwing. But what does it mean to throw? And what does that mean for our program?

Throw really does two things: it stops the program, and it finds a catch to execute. Let’s examine these ideas one at a time.

When JavaScript finds a throw keyword, the first thing it does is stop dead in its tracks. This event prevents any more functions from running. By stopping like this, it mitigates the risk of any further errors occurring and helps debug programs easily.

With the program halted, JavaScript will begin to trace the daisy chain of functions that were called in order to reach a catch statement. This chain is called the call stack (don’t worry – we’ll get to the call stack soon). The nearest catch that JavaScript finds is where the thrown exception emerges. If no try/catch is found, the exception throws, and the Node.js process exits, causing the server to restart.

Throwing by example

So far, we’ve been quite theoretical. Let’s take a look at an example:

function doAthing() {
    byDoingSomethingElse();
}
function byDoingSomethingElse() {
    throw new Error('Uh oh!');
}
function init() {
    try {
        doAthing();
    } catch(e) {
        console.log(e);
        // [Error: Uh oh!]
    }
}
init();

Here we can see that the init function has try/catch error handling in place. It calls a function, which calls another function, which in turn throws an error. It is at the point of error when the program halts and begins tracing the function that threw the error. Eventually it gets back to the init function and executes the catch statement. In the catch statement, we can decide to take an action, suppress the error, or even throw another error (to propagate upward).

Whenever a function is called, it’s put onto the stack; when it’s returned, it’s removed from the stack

Call stack

What we’re seeing in the above example is a worked example of the call stack. In order to function (like most languages), JavaScript utilizes a concept known as the call stack. But how does a call stack work?

Whenever a function is called, it’s put onto the stack; when it’s returned, it’s removed from the stack. It is from this stack that we derive the name “stack traces,” which you might have heard of. These are the big, scary-looking messages that we sometimes see when something is buggy in our program.

They often look like this:

Error: Uh oh!
at byDoingSomethingElse (/filesystem/aProgram.js:7:11)
at doAthing (/filesystem/aProgram.js:3:5)
at init (/filesystem/aProgram.js:12:9)
at Object.<anonymous> (/filesystem/aProgram.js:19:1)
at Module._compile (internal/modules/cjs/loader.js:689:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:700:10)
at Module.load (internal/modules/cjs/loader.js:599:32)
at tryModuleLoad (internal/modules/cjs/loader.js:538:12)
at Function.Module._load (internal/modules/cjs/loader.js:530:3)
at Function.Module.runMain (internal/modules/cjs/loader.js:742:12)

At this point, you might be wondering how a call stack helps us with Node.js error handling. Let’s talk about the importance of call stacks.

Your call stack provides your breadcrumbs, helping you trace back the way in which you came. The call stack also enables you to trace the origin of a failure so you can easily see which functions were called. In addition, it helps trace when the run-up to an error was created.

But wait, there’s some nuance to all this call stack stuff.

Where this gets hairy is the topic of anonymous functions, or functions that don’t have names. Why, you ask, would we have functions without names? Sometimes in our programs, we want to define small, throw-away functions that do a small thing. We don’t want to labor ourselves with the task of naming them, but it’s these anonymous functions that can cause us all kinds of headaches. An anonymous function removes the function name from our call stack, which makes our call stack significantly harder to use.

Note that naming functions in JavaScript is not that straightforward. So, let’s take a quick look at the different ways that you can define functions and address some of the pitfalls in function naming.

How to name functions

To understand function naming, let’s look at some examples:

// Anonymous function
const one = () => {};
// Anonymous functions
const two = function () {};
// Explicitly named function
const three = function explicitFunction() {};

Here we’ve got three example functions.

The first is a lambda (sometimes called a fat arrow function). Lambda functions are anonymous by virtue. Don’t get confused. The variable name “one” is not the function name. The function name is optionally passed after the keyword “function.” But in this example, we’re not passing anything at all, so our function is anonymous.

Note: It doesn’t help that some JavaScript runtimes, like V8, can sometimes guess the name of your function. This happens even if you don’t give it one. But without digressing too much, you don’t want to rely on this functionality because it’s not consistent.

Second, we’ve got a function expression. This is very similar to the first. It’s an anonymous function, but simply defined with the function keyword rather than the fat arrow syntax.

Finally, we have a variable declaration with an apt name: explicitFunction. It shows that the only function that is appropriately named is when you use the function keyword and pass the (optional) name. It’s generally a best practice to provide this name wherever you can to have more readable stack traces.

Handling async (callback) errors

At this point, you’re an aficionado of the error object, throw keyword, call stacks and function naming. So, let’s turn our attention to the curious case of handling asynchronous errors. Why? Because they don’t behave as you’d expect, and asynchronous programming is essential to every Node.js programmer.

Before we get too deep, let’s evaluate how JavaScript handles async tasks, and why we even need to do this.

JavaScript is a single-threaded programming language which in plain English, means that JavaScript runs using a single processor. By virtue of having a single processor, we get blocking, or conversely, non-blocking code. Blocking code refers to whether your program will wait for an async task to be complete before doing anything else. Whereas non-blocking code refers to where you register a callback to perform when the task completes.

Essentially, it’s worth mentioning that there are two main ways you can handle async in JavaScript: promises or callbacks. We’re deliberately ignoring async/wait here to avoid confusion because it’s simply sugar on top of promises.

For the purpose of this article, we’ll focus on promises. There is considerable industry consensus that for application coding, promises beats callbacks in terms of programming style and effectiveness. So for this article, we’ll ignore the callback pattern and assume that you’re going to opt for promises instead. A fine choice, we might add.

Note: Luckily for you, there are many ways to convert your callback-based code into promises. For example, you can use a utility like the built-in promisify or wrap your callbacks in promises like so:

var request = require('request'); //http wrapped module
function requestWrapper(url, callback) {
    request.get(url, function (err, response) {
        if (err) {
            callback(err);
        } else {
            callback(null, response);
        }
    })
}

We’ll handle this error, I promise!

Okay, so we’ve doubled down on promises. But what does this mean for our Node.js error handling?

Let’s take a look at the anatomy of a promise.

A promise in JavaScript is an object that represents a future value. Promises allow us to model asynchronous code like synchronous code by using the Promise API (which we’ll see later). It’s also worth noting that a promise usually comes in a chain, where one action executes, then another and so on.

But what does this all mean for Node.js error handling?

Promises handle errors quite elegantly and will catch any errors that preceded it in the chain. This is great for Node.js programmers because we can handle many errors in many functions within a single handler. Let’s take a look a the code below:

function getData() {
    return Promise.resolve('Do some stuff');
}
function changeDataFormat(){
    // ...
}
function storeData(){
    // ...
}
getData()
    .then(changeDataFormat)
    .then(storeData)
    .catch((e) => {
        // Handle the error!
    })

Here we see how you can roll up your error handling for all three different functions into one. Essentially, this behaves as if it’s wrapping all of your functions in a synchronous try/catch.

To catch or not to catch promises?

At this point, you might be wondering whether adding a catch to your promises is optional. Yes it’s optional, but you should always provide a catch handler.

Why? Because there are many ways your asynchronous calls can fail. Our code might timeout, it could have network issues or there might be a hardware failure. For all of these reasons, you should always instruct your program what to do in the case of a promise failure.

Remember the golden rule: always handle promise rejections.

The perils of async try/catch

We’re nearing the end now on our journey through Node.js error handling. But it’s the right time to identify a pretty big pitfall of async code and the try/catch statement.

You might have been wondering why the promise exposes a catch method and why we can’t just wrap our promise implementation in a try/catch. If you were to do this, the results would not be as you expect.

Let’s take an example:

try {
    throw new Error();
} catch(e) {
    console.log(e); // [Error]
}
try {
    setTimeout(() => {
        throw new Error();
    }, 0);
} catch(e) {
    console.log(e); // Nothing, nada, zero, zilch, not even a sound
}

Give that a good look. Do you see what’s happening here?

Try/catch is by default synchronous. That means that if an asynchronous function throws an error in a synchronous try/catch block, no error throws.

That’s definitely not what we want.

In summary, if you’re handling async error handling, you should really use the promises catch handler, which will allow you to effectively handle the async errors. But if you’re dealing with synchronous code, the try/catch will do just fine.

Handling errors with tooling

If an exception throws, but no programmer sees it, was it even thrown?

— Ancient Zen Buddhist programming proverb

We’ve got some good news and some bad news. If you’re handling errors as best you can in your application, awesome! That’s the good news. The bad news is that you’re only halfway there.

Handling errors in your program deals only with the things you know about. As much as we can write flawless code, mistakes happen. Rather than trying to prevent all failures from ever happening, we should try to know when a failure happens. We should also get the most information we can about the problem.

And that’s where an application performance monitoring tool comes into play. APMs come in all different shapes and sizes. Some analyze your log data or infrastructure; some capture and manage uncaught exceptions. When it comes to error handling, it makes sense to use an APM to get visibility into your application. There’s no point spending all this time optimizing stack traces, function names and the like if all this rich data is thrown into some log file on a desolate server.

By utilizing an APM such as Retrace, you can get insights into how your program is performing. You can also see how your database queries execute, the load on your servers and so on. All of this can drastically help you improve the speed of debugging and remediation time.

Retrace collects a lot of contextual data about what is occurring when an error is thrown. This data can be very useful for error reporting purposes. You can get a list of your customers or users being affected by an error, enabling you to see every unique stack trace, URL or method name impacted by the error. Download your free two week trial of Retrace today.

Node.JS Error handling and confidence in shipping

And that’s all we have for today. We hope that was a useful introduction to error handling in Node.js. At this point, you should feel more comfortable with the idea of throwing error objects and catching them in either synchronous or asynchronous code. Your applications will be significantly more robust and ready for production.

You may also want to try Stackify’s free, real-time code profiler, Prefix, to write better code on your workstation. Prefix works with .NET, Java, PHP, Node.js, Ruby, and Python.

Until next time, handle your errors and ship with confidence!

Improve Your Code with Retrace APM

Stackify's APM tools are used by thousands of .NET, Java, PHP, Node.js, Python, & Ruby developers all over the world.
Explore Retrace's product features to learn more.

Learn More

Want to contribute to the Stackify blog?

If you would like to be a guest contributor to the Stackify blog please reach out to [email protected]