Error Handling Fundamentals
Robust error handling is what separates a toy script from a production service. In Node.js, failures are everywhere — a file is missing, a network request times out, user input is malformed — and how you surface and recover from those failures determines whether your process logs a clean message or crashes with a cryptic stack trace. This page covers the building blocks: the Error object, throwing, the try/catch/finally construct, and the critical distinction between operational and programmer errors.
The Error object
Every error in JavaScript is (or should be) an instance of the built-in Error class. When you create one, you pass a human-readable message, and the runtime captures three useful properties.
| Property | Description |
|---|---|
message | The string you passed to the constructor — the what went wrong. |
name | The error’s type, e.g. "Error", "TypeError", "RangeError". |
stack | A string snapshot of the call stack at the moment the error was created. |
const err = new Error('Database connection failed');
console.log(err.message); // Database connection failed
console.log(err.name); // Error
console.log(err.stack); // Error: Database connection failed\n at ...
JavaScript ships several built-in subclasses that signal more specific failures: TypeError (wrong type), RangeError (value out of range), SyntaxError, and ReferenceError. Node.js also attaches a machine-readable code property to system errors (for example ENOENT for a missing file), which you should branch on instead of parsing the message text.
import { readFile } from 'node:fs/promises';
try {
await readFile('/no/such/file.txt');
} catch (err) {
console.log(err.name); // Error
console.log(err.code); // ENOENT
}
Always throw
Errorobjects, never strings or plain objects.throw 'boom'loses the stack trace and breaks tooling that expectserr.messageanderr.stack.
Throwing errors
You raise an error with the throw keyword. Throwing immediately stops execution of the current function and unwinds the call stack until a matching catch is found — or, if none exists, the process terminates.
function withdraw(balance, amount) {
if (amount <= 0) {
throw new RangeError('Amount must be positive');
}
if (amount > balance) {
throw new Error('Insufficient funds');
}
return balance - amount;
}
Throwing early, often called a guard clause, keeps the happy path flat and makes invalid states impossible to proceed from. Validate inputs at the top of a function and throw the most specific error type that fits.
try / catch / finally
The try/catch/finally statement is how you handle a thrown error gracefully. Code in the try block runs normally; if anything throws, control jumps to catch with the error bound to a parameter. The optional finally block runs afterward regardless of whether an error occurred — ideal for releasing resources.
function safeWithdraw(balance, amount) {
try {
const result = withdraw(balance, amount);
console.log(`New balance: ${result}`);
return result;
} catch (err) {
console.error(`Transaction rejected: ${err.message}`);
return balance;
} finally {
console.log('Transaction attempt complete');
}
}
safeWithdraw(100, 250);
Output:
Transaction rejected: Insufficient funds
Transaction attempt complete
Since ES2019 the catch binding is optional — write catch { when you do not need to inspect the error. Note that try/catch only catches synchronous throws and rejected Promises that you await. A callback that fails asynchronously will not be caught by a surrounding try block; see error-first callbacks and events for that case.
// This does NOT work — the throw happens in a later tick
try {
setTimeout(() => {
throw new Error('too late');
}, 100);
} catch (err) {
console.log('never reached');
}
With async/await, however, a rejected Promise surfaces as a normal throw at the await site, so the same try/catch handles both worlds.
async function loadConfig(url) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error('Could not load config:', err.message);
throw err; // re-throw so the caller can decide what to do
}
}
Re-throwing (
throw err) after logging is often the right move. Swallowing an error silently hides bugs; let it propagate to a layer that can actually handle it.
Operational vs programmer errors
Not all errors are equal, and conflating them is a common source of fragile applications. The distinction guides whether you should recover or crash.
Operational errors are expected runtime conditions in a correct program: a failed DNS lookup, a 404 response, an invalid request body, a full disk. These are part of normal operation and your code should anticipate and handle them — retry, return a 400, or surface a friendly message.
Programmer errors are bugs: calling a function with the wrong arguments, reading a property of undefined, a typo in a variable name. These represent broken code, not a broken world. You generally should not try to recover from them — the cleanest response is to let the process crash (ideally under a supervisor that restarts it) so the defect is visible and fixed.
| Aspect | Operational error | Programmer error |
|---|---|---|
| Cause | External/runtime condition | A bug in the code |
| Examples | Timeout, ENOENT, bad input | TypeError, undefined access |
| Strategy | Handle and recover | Fix the code; let it crash |
| Predictable? | Yes — anticipate it | No — it is a mistake |
Treating a programmer error as operational (wrapping every bug in a try/catch that logs and continues) leaves your app running in a corrupt, unpredictable state. Reserve recovery logic for genuinely operational failures.
Best practices
- Always throw
Errorinstances (or subclasses), never raw strings or objects, so stack traces andmessageare preserved. - Branch on
err.codefor Node system errors rather than matching onerr.message, which is unstable and locale-dependent. - Use guard clauses to validate inputs and throw the most specific error type (
TypeError,RangeError) early. - Use
finallyto clean up resources (close files, release locks) so they run on both success and failure paths. - Catch operational errors close to where you can act on them, and re-throw rather than silently swallowing.
- Let programmer errors crash the process under a supervisor instead of papering over bugs with broad
catchblocks. - Remember
try/catchdoes not catch asynchronous throws from timers or callbacks — only synchronous code and awaited Promises.