Before diving into best practices for error handling in Node.js applications, let’s picture a scenario. Imagine you've just been asked to fix a critical bug in a project that’s going live tomorrow by your company. The only developer who worked on it is away on vacation in a remote location, completely unreachable. You skim through the codebase and try running it but get a vague error message with numbers and symbols which is almost cryptic and difficult to trace. You figured initially that this would be an hour’s job but now you realize that all your plans for the night are cancelled as you have to figure out what the error is about and fix it. The clock ticks, the coffee gets cold, you fidget under the pressure. You promise yourself to never put anyone else in your situation.
This kind of feel-in-the-dark scenario is what happens in situations where you’re forced to deal with a codebase that is the product of bad error handling practices. You may proudly showcase your work to users and other developers without properly considering scenarios where things may go wrong and errors may happen. Without proper error messages or handling practices, developers are left in the dark. To avoid that frustration, you need a solid approach to managing errors in your Node applications.
Synchronous and Asynchronous Programming
Node.js is single-threaded, which means it processes one task at a time. However, with asynchronous programming, it gives the illusion of handling multiple tasks at once.Synchronous code refers to code that is handled line by line. It is similar to a waiter managing restaurant orders at the rate of a table at a time. If you were to approach such a Waiter and demand that he attend to both your table and say, table 6 at once, he may blatantly refuse because he is instructed to handle a table at a time, ensuring the food for table 6 is served before he checks on your table. This behavior in programming is known as blocking as the processing of one task essentially ‘blocks’ the others until it’s done. An example of a synchronous program is shown below:
const demureWaiter = (name) => {
console.log(`${name} is a careful Waiter who only serves one table at a time.`);
};
demureWaiter("John");
console.log("This second sentence talks about the situation in the restaurant.");
console.log("Customers look angry but the Waiter doesn't mind!");
With synchronous programming the result is quite straightforward; the code prints out each log line by line like you’re reading a good book in order without jumping from section to section. Here, you cannot read line 6 if you haven’t read line 5 so the task of reading line 6 must wait until line 5 is read:
John is a careful Waiter who only serves one table at a time.
This second sentence talks about the situation in the restaurant.
Customers look angry but the Waiter doesn't mind!
On the other hand, asynchronous programming allows for Node.js to process multiple tasks at the same time. It handles these asynchronous tasks using mechanisms like callbacks, promises, and async/await, which we’ll explore shortly. It simply starts one task that may take a while and then proceeds to another, making it non-blocking. This is quite useful because there are processes that could take a long time to complete making it impractical to wait for them. In such situations, synchronous programs would be less than ideal. As an analogy, Janet who works in the same restaurant as John handles orders by starting up with one table, and while their orders are being prepared moves on to another table to take an order:
const snappyWaiter = (name) => {
console.log(`${name} is a Waiter who moves quickly and attends to many tables at a time.`);
setTimeout(() => {
console.log("Wow! 30 tables were served in 10 minutes.");
console.log("Some orders were messed up however.");
}, 100);
};
snappyWaiter("Janet");
console.log("Customers look angry and the Waiter doesn't mind!");
Unlike the synchronous function above, this asynchronous function schedules another function to run (in this case, setTimeout()). If this code were synchronous you would expect the log output to be:
Janet is a Waiter who moves quickly and attends to many tables at a time.
Wow! 30 tables were served in 10 minutes.
Some orders were messed up however.
Customers look angry and the Waiter doesn't mind!
Instead we have a different result as shown below because setTimeout() schedules the work to run later (in this case after 100 milliseconds). Meanwhile, the synchronous code continues running immediately:
Janet is a Waiter who moves quickly and attends to many tables at a time.
Customers look angry and the Waiter doesn't mind!
Wow! 30 tables were served in 10 minutes.
Some orders were messed up however.
The Node.js event loop determines the order in which synchronous and asynchronous tasks run. It shows that synchronous code always runs before asynchronous code. In the example above, logging “Customers look angry and the Waiter doesn’t mind!” is synchronous and so it runs before the setTimeout() function which is asynchronous.
Asynchronous code includes mechanisms like callback functions which typically involve calling a function within another function as we did above, promises which are an improvement over callbacks as they limit the chances of callback hell and async/await which serve as syntactic sugar for promises. Understanding the event loop helps make sense of these mechanisms.
In summary, synchronous (sync) code blocks until it’s done. Asynchronous (async) code lets other work continue. Understanding this difference is key, because the error handling strategies we’ll explore next depend on whether your code is sync or async.
Callbacks and Error Handling
A callback function is a function that calls another function. It is one way to get programs to work asynchronously and save time, thereby making them efficient – if used correctly of course. When used wrongly, callbacks could lead to scary sounding concepts such as callback hell which looks as hellish as it sounds as you’ll soon find out. See below for a basic callback function with error handling included:
const specialRecipe = (name, callback) => {
if(typeof name !== "string"){
console.log("Oh no! An unpalatable error has occured!");
return;
} else{
console.log(`This ${name} recipe is the best in the world with the following preparation steps: `);
callback();
}
};
const friedEggsRecipe = () => {
console.log("Heat oil in pan, add salt to eggs, beat eggs, place in oil and flip for 3 seconds, pack up and eat.");
}
specialRecipe("fried eggs", friedEggsRecipe);
A callback function could contain both synchronous and asynchronous functions which would determine the order in which it runs as synchronous code runs first. Callback functions could also be nested, with one callback in another callback, its value being used by the next callback and so on. The term “callback hell” stems from this arrangement, where the callbacks are nested so far out that the code becomes completely unreadable as future developers working on it beg the heavens for mercy. A callback hell may look like:
getRecipe("fried eggs", function (err, food) {
if (err) {
console.log(err);
} else {
recipe(food, function (err, cooks) {
if (err) {
console.log(err);
} else {
bestCooks(cooks, function (err, ingredients) {
if (err) {
console.log(err);
} else {
freshIngredients(ingredients, function (err, size) {
if (err) {
console.log(err);
} else {
servingSize(size, function (err) {
if (err) {
console.log(err);
} else {
console.log("All information regarding this recipe shown!");
}
});
}
});
}
});
}
});
}
});
This pyramid of doom (its actual name) with its repeated error handling and difficulty to read and maintain must make you realize why it’s given such a charming name.
Callbacks can be used for error handling, as shown in the earlier example. While this approach works, it isn’t always the best solution. The main drawback is that callbacks often lead to repetitive error checks and messy nesting, making code harder to maintain. However, you may trade it for a less complex and less error-prone way to handle errors by using promises. You could think of them as a neat contract that either delivers success or failure, without the messy nesting.
Error Handling with Promises
Promises are a more compact and readable way to handle asynchronous code with less chances of running into hell – callback hell I mean. You can re-write an earlier example specialRecipe() using promises:
const specialRecipe = (name) => new Promise((resolve, reject) => {
if(typeof name !== "string"){
reject("Oh no! An unpalatable error has occured!")
} else{
resolve(`This ${name} recipe is the best in the world with the following preparation steps: `)
}
});
specialRecipe("fried eggs").then((result) => {
console.log(result);
console.log("Heat oil in pan, add salt to eggs, beat eggs, place in oil and flip for 3 seconds, pack up and eat.");
})
.catch((error) => {
console.log(error)
})
Instead of having to deal with nested callbacks, promises make your task much easier by introducing chained .then and .catch allowing for better readability and maintainability as well as cleaner error management. In this setup, .then() ensures successful results are neatly handled, while .catch() centralizes errors, preventing them from being lost or hidden deep in nested functions.
Async/Await Error Handling
While promises are great, they can still become cumbersome with poor practices. In order to effectively manage this, async/await was introduced for asynchronous programming. It is what’s known as the syntactic sugar of promises – which unfortunately is not a heavenly sugary snack as I once thought – but a kind of syntax improvement over promises, making code easier to read and write but not actually adding new functionality. We use try/catch within async/await to help catch errors immediately. try/catch may also be used for synchronous functions as well and are in fact considered ideal in this scenario since they catch errors immediately rather than later. They act exactly as you’d expect, by trying some task and then catching any errors that may come up:
const demureWaiter = (name) => {
try{
console.log(`${name} is a careful Waiter who only serves one table at a time.`);
} catch(error){
console.log(`An error occured: ${error}`)
}
};
demureWaiter("John");
console.log("This second sentence talks about the situation in the restaurant.");
console.log("Customers look angry but the Waiter doesn't mind!");
In the code above you try to output the string to the console and in the event of any errors, they’ll be caught by the catch block. You can use this in an async/await function by re-writing the specialRecipe() function once more:
const specialRecipe = async(name) => {
try{
console.log(`This ${name} recipe is the best in the world with the following preparation steps: `);
} catch(error){
console.log(`Oh no! An unpalatable error has occured: ${error}`);
}
}
const cookRecipe = async() => {
await specialRecipe("fried eggs");
console.log("Heat oil in pan, add salt to eggs, beat eggs, place in oil and flip for 3 seconds, pack up and eat.");
};
cookRecipe();
await pauses until the promise returned by the async function resolves or rejects. It makes asynchronous code appear synchronous. It is often preferred over .then() chaining because it keeps code linear and avoids ‘callback pyramid’ style nesting. An advantage of the try/catch used is that it separates success from error handling cleanly.
With this you’ve seen a few of the ways to handle Node.js errors. Now you’ll find out what the best practices are when dealing with errors.
Best Error Handling Practices in Node.js
In order to ensure that your code is readable, maintainable and easy to work with, here are a few suggestions you could add to your error handling arsenal:
1. Use a Global Error Handler
This allows you to centralize your error handling logic in one place, which is great for code organization. If you use Express, it might entail writing a custom middleware:
app.use((err, req, res, next) => {
console.error(err);
res.status(500).send("Some crazy error has occured!")
})
While try/catch blocks are great, doing it this way also helps to avoid their excessive use and makes debugging much easier.
2. Use try/catch for async/await
Ensure you wrap your async/await code in try/catch so as to handle any errors that may slip by.
3. Watch Out for Crashes
Ensure you listen for unhandled errors by alerting them and then exiting gracefully. Also make sure you avoid silent failures as much as you can.
4. Practice Input Validation
This helps prevent invalid data. You could use a robust library like Joi to enforce this.
5. Return User-Friendly Error Messages
Let your error messages be clear enough so developers could debug easily and users could have a clue as to why some operation failed.
6. Create Custom Error Classes
This helps avoid bugs by extending the built-in Error class:
class ValidationError extends Error {
constructor(message){
super(message);
this.name = "ValidationError"
}
}
7. Utilize Logging Libraries
Libraries such as winston or pino help with structured logging. Don’t refrain from using them as needed.
With the above in place, you’ll be well on your way to mastering robust error handling practices in Node.js.
Conclusion
Proper error handling is a hallmark of good developers. By ensuring you follow the suggestions in this article, you would be able to write more durable and professional code that gracefully handles unexpected inputs and conditions so that future you and other developers who work on your code won’t have to fumble in the dark, drowning in cups of coffee at odd hours.