Node.js Error Handling
Robust Node.js applications handle errors deliberately at every layer, from callback conventions to synchronous try/catch to last-resort process-level safety nets.
The Error-First Callback Convention
Before promises existed, Node.js established a convention for asynchronous callbacks: the first argument is always reserved for an error (or null if none occurred), and subsequent arguments carry the successful result. This convention is called 'error-first callbacks' and still appears throughout core APIs and older libraries.
Handling an error-first callback
const fs = require('fs');
fs.readFile('data.txt', 'utf8', (err, data) => {
if (err) {
console.error('Read failed:', err.message);
return; // stop here, don't touch `data`
}
console.log('File contents:', data);
});try/catch for Synchronous and Async Code
try/catch handles exceptions thrown by synchronous code directly, and — when combined with async/await — it also handles rejected promises, since await re-throws a promise's rejection reason as a regular exception inside the function.
try/catch with JSON.parse and async/await
const fs = require('fs/promises');
async function parseConfig(path) {
try {
const raw = await fs.readFile(path, 'utf8');
return JSON.parse(raw); // throws SyntaxError on bad JSON
} catch (err) {
if (err.code === 'ENOENT') {
console.error('Config file not found:', path);
} else if (err instanceof SyntaxError) {
console.error('Config file has invalid JSON');
} else {
console.error('Unexpected error:', err.message);
}
return null;
}
}Notice how the catch block branches on err.code and the error's constructor. Node's built-in errors carry a `code` string like ENOENT (file not found) or EACCES (permission denied), which is far more reliable to check than parsing the error message text.
Custom Error Classes
For application-specific failures, extend the built-in Error class. This lets calling code distinguish your errors from generic ones using instanceof, while still getting a proper stack trace.
Defining and throwing a custom error
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
function validateAge(age) {
if (typeof age !== 'number' || age < 0) {
throw new ValidationError('Age must be a non-negative number', 'age');
}
return age;
}
try {
validateAge(-5);
} catch (err) {
if (err instanceof ValidationError) {
console.error(`Invalid field '${err.field}': ${err.message}`);
} else {
throw err;
}
}process-Level Safety Nets
Sometimes an error slips past every try/catch — a bug in a callback, a rejected promise nobody awaited. Node lets you listen for these at the process level, but they should be treated as a last resort for logging and graceful shutdown, not a substitute for proper error handling.
Listening for uncaught exceptions and unhandled rejections
process.on('uncaughtException', (err) => {
console.error('Uncaught exception, shutting down:', err);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled promise rejection:', reason);
process.exit(1);
});Exercise: Node.js Error Handling
Why doesn't a `try/catch` block catch an error thrown inside a `setTimeout` callback?