Effective error handling is key to building robust applications. By anticipating potential failures and responding gracefully, you can avoid unexpected crashes and provide a smoother user experience. Whether you’re catching synchronous exceptions or managing asynchronous failures, following best practices will keep your code clean, maintainable, and predictable.
In this guide, we’ll explore patterns and tips for handling errors in JavaScript—covering everything from try/catch
blocks and custom error types to Promises, async/await
, and centralized logging.
Understanding Error Types
JavaScript has several built-in error classes like Error
, TypeError
, ReferenceError
, and more. You can also create custom error types by extending the base Error
class:
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}
function validate(user) {
if (!user.email) {
throw new ValidationError('Email is required');
}
}
Key points:
- Built-in errors cover common issues.
- Custom errors help distinguish domains (validation vs network).
- Always set the
name
property so you can identify error types later.
Using try/catch Effectively
The simplest way to handle synchronous errors is with try/catch
. Enclose only the code you expect might fail:
try {
const result = computeSomething(input);
console.log('Result:', result);
} catch (err) {
console.error('Compute failed:', err.message);
}
Tips:
-
Keep
try
blocks small. Avoid wrapping large chunks of code; focus on the risky lines. - Handle or rethrow. If you can’t recover, add context and rethrow:
try {
loadConfig();
} catch (err) {
throw new Error(`Config load error: ${err.message}`);
}
- Always log when you catch. At minimum, log the message and stack trace.
Tip: Use source maps in production so stack traces point to your original code.
Working with Promises
Promises represent eventual success or failure. Always attach a .catch
handler to avoid unhandled rejections:
fetch('/api/data')
.then(response => response.json())
.then(data => console.log(data))
.catch(err => console.error('Fetch error:', err));
Common pitfalls:
- Forgetting
.catch
at the end leads to uncaught promise rejections. - Chaining multiple
.then
calls without one final.catch
.
For a deeper dive into Promises and their error flows, check out the JavaScript Promise When Guide.
Async/Await Patterns
async/await
makes asynchronous code look synchronous—but you still need try/catch
:
async function getUserData(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('Network response not ok');
return await res.json();
} catch (err) {
console.error('getUserData failed:', err);
throw err; // rethrow if caller needs to know
}
}
Best practices:
- Use a single
try/catch
perasync
function. - Validate response statuses before parsing JSON.
- Consider fallback values or user-friendly messages.
Centralized Error Logging
In larger apps, sending errors to a logging service helps you monitor and analyze failures:
function logError(err, context = {}) {
const payload = {
message: err.message,
stack: err.stack,
context,
timestamp: Date.now(),
};
// Send to external service
fetch('/log', { method: 'POST', body: JSON.stringify(payload) });
}
try {
riskyOperation();
} catch (err) {
logError(err, { userId: currentUser.id });
}
Key tips:
- Include context: user ID, route, inputs.
- Throttle or batch requests to avoid flooding.
- Mask sensitive data in logs.
Quote: “If it isn’t logged, it didn’t happen.”
Handling Errors in Callbacks
Older Node.js and browser APIs use error-first callbacks:
fs.readFile('file.txt', 'utf8', (err, data) => {
if (err) {
console.error('Read failed:', err);
return;
}
console.log('File contents:', data);
});
Best practices:
- Always check the
err
parameter first. - Use named functions for readability:
function handleFile(err, data) {
if (err) return console.error(err);
processData(data);
}
fs.readFile('file.txt', 'utf8', handleFile);
Learn more about callbacks in What is a JavaScript callback?.
Best Practices Summary
- Use
try/catch
for predictable failures. - Never skip
.catch
on Promises or ignore rejections. - Validate external responses before using data.
- Centralize logging with context.
- Differentiate error types with custom classes.
- Prefer
async/await
for readability but handle errors.
Implementing these patterns leads to cleaner code, fewer surprises in production, and a better developer experience.
Conclusion
Error handling often feels like plumbing—unseen but critical. By adopting structured practices such as scoped try/catch
, proper Promise chaining, and centralized logging, you make your code more resilient and maintainable. Custom error types help you target specific issues, while consistent logging ensures you spot trends before they escalate. Whether you’re building a small front-end widget or a large Node.js service, these patterns scale with your application. Start applying them today to reduce bugs, improve debugging speed, and deliver a stable experience to your users.
Top comments (0)