Skip to content
Node.js nd error-handling 5 min read

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.

PropertyDescription
messageThe string you passed to the constructor — the what went wrong.
nameThe error’s type, e.g. "Error", "TypeError", "RangeError".
stackA 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 Error objects, never strings or plain objects. throw 'boom' loses the stack trace and breaks tooling that expects err.message and err.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.

AspectOperational errorProgrammer error
CauseExternal/runtime conditionA bug in the code
ExamplesTimeout, ENOENT, bad inputTypeError, undefined access
StrategyHandle and recoverFix the code; let it crash
Predictable?Yes — anticipate itNo — 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 Error instances (or subclasses), never raw strings or objects, so stack traces and message are preserved.
  • Branch on err.code for Node system errors rather than matching on err.message, which is unstable and locale-dependent.
  • Use guard clauses to validate inputs and throw the most specific error type (TypeError, RangeError) early.
  • Use finally to 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 catch blocks.
  • Remember try/catch does not catch asynchronous throws from timers or callbacks — only synchronous code and awaited Promises.
Last updated June 14, 2026
Was this helpful?