Hey there! I’m the CTO of a web3 startup, and before that, I was a senior engineer working on infrastructure and full-stack systems at Google. I’ve learnt a few things along the way about coding, and today, I’m excited to dive into something that we often overlook until it’s too late: error handling and logging.
When I first jumped ship from Google to start my own venture, it was all about moving fast, hacking stuff together, and making things work. One thing that I initially put on the back burner was implementing a robust error handling and logging system. After all, why waste time preparing for errors when you can just code to avoid them, right? Well, fast forward to countless hours spent debugging and troubleshooting, and I’ve learnt my lesson.
In this post, I’ll share with you some practical tips and best practices on error handling and logging that I’ve picked up, alongside examples from my own experiences. If I knew back then what I know now, I would have set up error handling right from the start. But as they say, hindsight is 20/20.
Let’s get into it!
Why is Error Handling Important?
Before we dive into the practicalities, let’s take a moment to consider why error handling is important. Well, for starters, errors are inevitable. No matter how careful you are, how experienced your team is, or how thorough your QA process might be, things can and will go wrong. That’s just the reality of software development.
- Debugging: A proper error handling and logging system can make debugging significantly easier and faster. By having detailed error messages and logs, you can trace back the series of events that led to the error, making it easier to reproduce and fix.
- Resilience: Handling errors appropriately can make your system more resilient. Instead of crashing the whole system, a well-placed try/catch can contain the error and allow the system to recover and continue running.
- User Experience: Users don’t like seeing raw error messages or, worse, having the app crash on them. Good error handling can allow you to provide user-friendly error messages and fallbacks, leading to a better user experience.
- Security: Detailed error messages can reveal more about your system than you might want. You can avoid potential security risks by controlling what gets revealed in an error message.
Getting Hands-on with Error Handling
Now, let’s get down to business. How do you handle errors effectively in a backend environment? There’s no one-size-fits-all answer, as it heavily depends on your tech stack, team size, project complexity, and a dozen other factors. That being said, there are some universally good practices to adhere to.
1. Centralized Error Handling
It’s a good practice to centralize your error handling as much as possible. This approach simplifies code readability and maintainability and ensures consistency. If you’re using a framework like Express.js, you can use middleware for this.
Here’s a simplified example:
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send('Something broke!');
});
This error-handling middleware would catch errors that occur in your route handlers and send a generic response. But what about sending more user-friendly messages or dealing with different error types? That’s where custom error classes come in.
2. Custom Error Classes
Custom error classes in JavaScript (or whatever language you’re using) allow you to create specific error types, each potentially having its own error handling. Here’s a simple example:
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
this.statusCode = 400;
}
}
class DatabaseError extends Error {
constructor(message) {
super(message);
this.name = "DatabaseError";
this.statusCode = 500;
}
}
With these classes, you can throw specific errors in your code, and your error-handling middleware can behave differently depending on the error type.
app.use((err, req, res, next) => {
if (err instanceof ValidationError) {
res.status(err.statusCode).send(err.message);
} else if (err instanceof DatabaseError) {
res.status(err.statusCode).send('A database error occurred');
} else {
res.status(500).send('Something broke!');
}
});
These are just simplified examples. In a real-world scenario, you might log the errors to an external service, handle more error types, etc.
3. Proper use of Try/Catch
Try/catch blocks are your bread and butter for catching errors as they occur. It’s important only to catch errors that you can handle. If you can’t handle the error (for example, you don’t know why it would occur), it’s usually better to let it bubble up to the global error handler.
Here’s a good use of a try/catch block:
try {
const user = await getUserFromDb(userId);
} catch (err) {
if (err instanceof NotFoundError) {
// We know why this error occurred and we can handle it
return createNewUser(userId);
}
// We can't handle any other errors, rethrow them
throw err;
}
Here, we’re only catching a specific error that we know might happen and that we can handle. Any other errors get rethrown and can be handled by our global error handler.
The Importance of Good Logging
Now, while error handling is about dealing with errors as they occur, logging is about recording what happened so you can look back on it in the future. This can be extremely helpful when debugging.
There are several things you should consider when implementing logging:
- What to log: You want to log any information that might be useful for debugging. This can include input parameters, output results, and any intermediate variables. However, be aware of privacy and security issues. Never log sensitive information like passwords.
- When to log: Ideally, you want to log as much as possible, but there’s always a trade-off between detail and performance/storage. Consider using different log levels (error, warning, info, debug) to control this.
- Where to log: For local development, logging into the console might be sufficient. But for a production system, you’ll want to use a logging service that can handle large volumes of logs, manage retention policies, and provide search and analysis tools. This could be a cloud service like Google’s Cloud Monitoring, a self-hosted solution like Elasticsearch, or a log management service like Loggly or Datadog.
Here’s an example of how you might log a function’s input and output:
function add(a, b) {
console.log(`add was called with ${a} and ${b}`);
const result = a + b;
console.log(`add result is ${result}`);
return result;
}
In a production scenario, you’d replace console.log with a call to your logging library or service, and you might add more detail (like a timestamp or the name of the function).
Final Thoughts
I hope this post has given you a practical insight into error handling and logging in backend development. If there’s one thing I want you to take away from this, it’s that error handling and logging are not an afterthought. They are an integral part of your code that can save you countless hours of debugging and many headaches. So, invest the time upfront to set up a good error handling and logging system. You’ll thank yourself later!
Stay tuned for future posts where I plan to delve deeper into some of these topics. If you have any questions, feel free to drop a comment or reach out to me. Happy coding!
For more coding tutorials, please subscribe to my YouTube channel: https://www.youtube.com/@CtrlAltVictoria and Twitter https://twitter.com/ctrlaltvictoria 💕🚀