Every server fails eventually. A file will not exist. A database connection will drop. A network request will time out. A user will send malformed data. The difference between a reliable Node.js application and a fragile one is rarely whether errors happen — they always do — it is whether the application anticipated them and handled them deliberately.
Node.js has several distinct categories of errors and several distinct mechanisms for handling them, and the mechanism you need depends entirely on which category of error you are dealing with. Using the wrong one is one of the most common sources of crashed Node.js processes in production.
Categories of Errors in Node.js
Before reaching for a specific handling technique, it is worth understanding the kind of error you are dealing with.
Operational errors are expected failures that happen during normal program operation — a file that does not exist, a failed database connection, invalid user input, a network timeout. These are not bugs. They are conditions your application should anticipate and handle gracefully.
Programmer errors are bugs — calling a function with the wrong arguments, attempting to read a property of undefined, a typo in a variable name. These indicate something is wrong with the code itself, not with the environment or the input.
This distinction matters because the correct response differs. Operational errors should be caught and handled — log them, return a meaningful response, retry the operation. Programmer errors generally should not be caught and silently ignored; doing so hides bugs rather than fixing them. The goal with programmer errors is to surface them clearly, often by letting the process crash in development so the bug gets fixed.
The Error Object
JavaScript's built-in Error object is the foundation of error handling in Node.js. It carries a message describing what went wrong and a stack trace showing where it happened.
const err = new Error('Something went wrong');
console.log(err.message); // Something went wrong
console.log(err.stack); // Error: Something went wrong\n at Object.<anonymous> ...
Built-in Error Subtypes
JavaScript provides several built-in subtypes of Error, each signalling a more specific kind of failure:
| Error type | When It Is Thrown |
|---|---|
Error | The generic base type |
TypeError | A value is not of the expected type — calling a non-function, accessing a property of undefined |
RangeError | A value is outside an allowed range — invalid array length, invalid number precision |
SyntaxError | Malformed code — usually from JSON.parse() on invalid JSON |
ReferenceError | A reference to a variable that does not exist |
JSON.parse('{ invalid json'); // SyntaxError: Unexpected token i in JSON
null.someProperty; // TypeError: Cannot read properties of null
Custom Error Classes
For application-specific errors, it is common and good practice to extend the built-in Error class:
class NotFoundError extends Error {
constructor(message) {
super(message);
this.name = 'NotFoundError';
this.statusCode = 404;
}
}
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = 'ValidationError';
this.statusCode = 400;
this.field = field;
}
}
function findUser(id) {
const user = database.find(id);
if (!user) {
throw new NotFoundError(`User with id ${id} not found`);
}
return user;
}
Custom error classes let you carry structured, application-specific information alongside the message — a status code, a field name, an error code — which makes handling the error further up the call stack much easier than parsing the message string.
Synchronous Error Handling — try/catch
For synchronous code, try/catch is the standard JavaScript mechanism. Code that might throw is placed in the try block; the catch block runs if it does.
function parseConfig(jsonString) {
try {
return JSON.parse(jsonString);
} catch (err) {
console.error('Invalid config JSON:', err.message);
return null;
}
}
try/catch only catches errors thrown synchronously within the try block. It does not catch errors from callbacks scheduled to run later, even if those callbacks are defined inside the block:
try {
setTimeout(() => {
throw new Error('This will not be caught');
}, 100);
} catch (err) {
console.log('This catch block never runs');
}
By the time the setTimeout callback runs and throws, the try/catch block has already finished executing. This is a critical limitation to understand — try/catch works for synchronous code and for awaited Promises (covered in the async/await topic), but not for plain callbacks or unhandled async code.
Asynchronous Error Handling — by Pattern
Each asynchronous pattern in Node.js has its own correct way of handling errors, and mixing them up is a common source of bugs.
Callbacks — the Error-First Convention
As covered in the callbacks topic, Node.js callbacks follow the convention of passing an error as the first argument. Checking it is the entire error handling mechanism:
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) {
console.error('Failed to read file:', err.message);
return;
}
console.log(data);
});
There is no try/catch involved here. The error is data, passed through the normal function call, and it is the developer's responsibility to check for it every time.
Promises — .catch()
A Promise chain's rejection is handled by .catch(), which catches a rejection from any point earlier in the chain:
readFilePromise('config.json')
.then((data) => JSON.parse(data))
.catch((err) => {
console.error('Failed:', err.message);
});
async/await — try/catch
Inside an async function, await converts a Promise rejection into a thrown exception, which means ordinary try/catch works correctly:
async function loadConfig() {
try {
const data = await fs.promises.readFile('config.json', 'utf8');
return JSON.parse(data);
} catch (err) {
console.error('Failed:', err.message);
return null;
}
}
This is one of the practical advantages of async/await over raw Promise chains — error handling uses the same construct as synchronous code, rather than a separate .catch() method.
Unhandled Errors
When an error is not caught anywhere, Node.js has specific behaviour depending on the type of error.
Uncaught Exceptions
A thrown error that is never caught by any try/catch is an uncaught exception. By default, Node.js prints the error and its stack trace, then terminates the process immediately.
function riskyOperation() {
throw new Error('Something broke');
}
riskyOperation(); // process crashes
This is intentional, not a flaw. An uncaught exception means the program reached a state it does not know how to handle — continuing to run risks operating on corrupted state. Crashing immediately is safer than continuing silently.
process.on('uncaughtException')
Node.js allows you to listen for uncaught exceptions globally, as a last line of defence:
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);
// Log to an external service, then exit
process.exit(1);
});
This should be used carefully. It is not a substitute for proper error handling throughout the application — it is a safety net for logging and graceful shutdown when something genuinely unexpected slips through. The Node.js documentation explicitly recommends against trying to "resume normal operation" after an uncaught exception, because the application's state is no longer reliable. The correct pattern is to log the error and exit the process, ideally letting a process manager restart it cleanly.
Unhandled Promise Rejections
A Promise that rejects with no .catch() anywhere in its chain produces an unhandled rejection.
async function loadData() {
throw new Error('Failed to load');
}
loadData(); // no .catch() — unhandled rejection
In modern versions of Node.js (15 and later), an unhandled rejection terminates the process by default, with the same severity as an uncaught exception. In earlier versions, it only printed a warning. This change reflects a shift toward treating unhandled rejections as seriously as uncaught exceptions — both represent an error the application did not account for.
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection:', reason);
process.exit(1);
});
The same caution applies here as with uncaughtException — this is a safety net for logging before shutdown, not a mechanism for ignoring rejected Promises throughout the application.
Error Handling in an HTTP Server Context
Error handling matters most in the context it is most consequential — a server handling requests from real users. An unhandled error in a single request should not crash the entire server and disconnect every other user currently connected.
const http = require('http');
const server = http.createServer(async (req, res) => {
try {
const data = await fetchUserData(req.url);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
} catch (err) {
console.error('Request failed:', err.message);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
}
});
Wrapping the request handler logic in try/catch ensures that a failure in handling one request returns an error response to that one user, while the server itself keeps running and continues serving everyone else. This pattern — catching errors at the boundary of each request — is foundational to how Express's error handling middleware works, which is covered in detail in the Express research series.
Logging Errors Properly
Catching an error is only useful if the error is recorded somewhere useful. console.error() is sufficient for local development, but production applications typically need structured logging — capturing the timestamp, the error message, the stack trace, and relevant context (which request caused it, which user was involved) in a format that can be searched and monitored.
function logError(err, context = {}) {
console.error(JSON.stringify({
timestamp: new Date().toISOString(),
message: err.message,
stack: err.stack,
...context,
}));
}
try {
await processOrder(orderId);
} catch (err) {
logError(err, { orderId, userId });
throw err;
}
In production Node.js applications, this is typically handled by a dedicated logging library — winston and pino are the most widely used — which provides structured output, log levels, and integration with external monitoring services.
Common Mistakes in Error Handling
Swallowing Errors Silently
try {
await riskyOperation();
} catch (err) {
// nothing here — error disappears completely
}
An empty catch block hides failures rather than handling them. At minimum, log the error. Silently swallowing an error means the application continues running in a state that may be broken, with no record of why.
Catching Errors Too Broadly
try {
const user = await fetchUser(id);
const orders = await fetchOrders(user.id);
const total = calculateTotal(orders);
await saveReport(total);
} catch (err) {
console.error('Something failed');
}
A single try/catch wrapping four unrelated operations gives no indication of which one failed. When the specific failure point matters for the response or the recovery logic, it is worth narrowing the scope of the try block or checking err for identifying information, such as a custom error type.
Mixing Error Handling Patterns Incorrectly
async function getData(callback) {
try {
const data = await fetchData();
callback(null, data);
} catch (err) {
callback(err); // fine — mixing patterns deliberately here is correct
}
}
This particular example is actually fine — it is a deliberate bridge between async/await and a callback-based interface. The mistake to avoid is the opposite: forgetting that a try/catch around code that uses .then() internally without await will not catch the Promise's rejection, because the rejection happens asynchronously, after the try block has already completed.
Summary
| Concept | What It Means |
|---|---|
| Operational errors | Expected failures — invalid input, network issues — that should be caught and handled |
| Programmer errors | Bugs in the code itself — should be surfaced and fixed, not silently caught |
| Custom error classes | Extending Error to carry structured, application-specific information |
try/catch | Catches synchronous errors and awaited Promise rejections |
| Error-first callbacks | The Node.js convention of checking err as the first callback argument |
.catch() | Handles a rejection anywhere earlier in a Promise chain |
| Uncaught exception | A thrown error with no try/catch — crashes the process by default |
| Unhandled rejection | A rejected Promise with no .catch() — crashes the process by default (Node.js 15+) |
process.on('uncaughtException') | A last-resort global handler for logging before shutdown |
| Request-level error handling | Wrapping individual request handlers so one failure does not crash the server |