---
title: "04 — Error Handling in Node.js — Knowing Which Error You Have"
uid: error-handling
tags: ["errors", "nodejs", "async", "debugging", "roadmap:nodejs", "error-handling"]
excerpt: "Errors come in distinct kinds — a network glitch and a TypeError look similar in a log, but one is recoverable and the other is a bug. The strategy depends on which kind you're holding."
date: 2026-08-13T03:27:57+0000
source: https://www.aveshina.my.id/en/blog/error-handling
---

"Wrap it in try/catch and move on" was my error strategy, and it treated every error like the same problem. The model that finally stuck: **errors come in distinct kinds, and the right response depends entirely on which kind you're holding.** A network glitch and a TypeError from my own code look similar in a log, but one is recoverable and the other is a bug [1].

The framing that landed for me is the taxonomy. There are four kinds worth separating, and each has a different remedy.

## The operating-error versus programmer-error split

Before the taxonomy, the single distinction that matters most is **operational error versus programmer error** [1]:

- **Operational error** — something went wrong in a correct program. The file was missing, the database was down, the user sent bad input. The code is fine; the world was uncooperative. The right response is to handle it — retry, fall back, return a 4xx to the client.
- **Programmer error** — a bug. I called a function with the wrong type, referenced an undefined variable, mutated a shared structure. The code is wrong; no amount of retrying will fix it because the same input will fail the same way. The right response is to log it, crash, and let the process restart in a clean state.

This split is the reason "catch everything and keep going" is a trap. Catching a programmer error and continuing means running a program in a corrupted state, which produces worse failures downstream. Catching an operational error and crashing means a transient blip takes down a server. Knowing which is which is most of the job.

## System errors

System errors happen when Node.js asks the operating system to do something and the OS refuses. Reading a missing file, connecting to a port nothing is listening on, hitting a broken pipe. These are instances of Node's SystemError class and carry useful properties — code (like ENOENT or ECONNREFUSED), syscall (like open or connect), and errno [2].

```
import { readFileSync } from 'fs';

try {
  readFileSync('/does/not/exist');
} catch (err) {
  console.log(err.code);    // 'ENOENT'
  console.log(err.syscall); // 'open'
}
```

System errors are operational errors almost by definition — the OS state caused them, and retrying or degrading gracefully is the right move. The code property is the part I branch on, because the error message text can vary by locale but ENOENT is stable.

## User-specified errors

When I want to signal a domain problem to the caller — "you tried to withdraw more than your balance," "that email is already registered" — I throw my own error, and the idiomatic way is to extend the base Error class [3].

```
class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = 'ValidationError';
    this.field = field;
  }
}

throw new ValidationError('Email already registered', 'email');
```

Subclassing gives me a name to switch on at the call site, plus whatever domain-specific properties (field, code, statusCode) the handler needs. The stack property is inherited and points at where the error was thrown — useful in logs, since the stack trace is how I trace back from a symptom to the line that caused it [4].

## Assertion errors

Assertion errors are a special case used mostly in tests and runtime invariants. The built-in assert module throws an AssertionError when an expression is not truthy [5].

```
import assert from 'assert/strict';

assert.strictEqual(response.status, 200);
// throws AssertionError if status !== 200
```

In test code, assertions are the whole mechanism — the test runner catches them and reports pass/fail. In application code I use them sparingly, for invariants that should be impossible to violate: "if we got here, the user object must be loaded." An assertion firing in production is a programmer error by definition, and crashing is the correct response.

## JavaScript errors

The last kind is the language itself complaining. TypeError (calling a method on undefined), RangeError (a number out of bounds), ReferenceError (an undefined variable), SyntaxError (the parser gave up). These are almost always programmer errors — they mean my code is wrong [6]. The MDN error reference catalogs the full set, and reading the specific type usually points at the bug directly: a TypeError: Cannot read properties of undefined (reading 'map') tells me a variable I expected to be an array is actually undefined, and the fix is upstream of the line that threw.

## Handling them in async code

The one wrinkle worth flagging is that **try/catch only catches synchronous throws and await-ed promise rejections** [1]. A rejection on a promise nobody awaited becomes an unhandledRejection, and historically that crashed the process in newer Node versions. The discipline is: every promise gets either an await inside a try/catch, a .catch(), or a deliberate "fire and forget" with a logged rejection. Errors that escape all handlers become the fatal kind, and pretending otherwise is how servers mysteriously restart.

## How I use this

The habit I keep is a single question when an error fires: **is this operational or a bug?** Operational → handle it, maybe retry, return a clean response to the client. Bug → log the stack and let the process die (the process manager restarts it; the crash surfaces the bug instead of hiding it). I subclass Error for domain errors so handlers can switch on name or code. And I branch on err.code for system errors rather than the message text. The taxonomy — system, user, assertion, JavaScript — is what turns "something threw" into a decision I can make.

## References

[1] Sematext, "Node.js Error Handling Best Practices," Sematext Blog. [Online]. Available: [https://sematext.com/blog/node-js-error-handling](https://sematext.com/blog/node-js-error-handling)

[2] OpenJS Foundation, "Node.js Errors — Class: SystemError," Node.js API Docs. [Online]. Available: [https://nodejs.org/api/errors.html#errors_class_systemerror](https://nodejs.org/api/errors.html#errors_class_systemerror)

[3] Honeybadger, "A Comprehensive Guide To Error Handling In Node.js," Honeybadger Blog. [Online]. Available: [https://www.honeybadger.io/blog/errors-nodejs/](https://www.honeybadger.io/blog/errors-nodejs/)

[4] cloudhadoop, "Multiple Ways to Log The Stack Trace in Node.js." [Online]. Available: [https://www.cloudhadoop.com/nodejs/print-stack-trace-error/](https://www.cloudhadoop.com/nodejs/print-stack-trace-error/)

[5] OpenJS Foundation, "Node.js Assert," Node.js API Docs. [Online]. Available: [https://nodejs.org/api/assert.html#new-assertassertionerroroptions](https://nodejs.org/api/assert.html#new-assertassertionerroroptions)

[6] Mozilla, "JavaScript error reference," MDN Web Docs. [Online]. Available: [https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors)

```quiz
Q: What is the key distinction that drives error-handling strategy in Node.js?
- Synchronous vs asynchronous errors
- Operational errors (the world failed) vs programmer errors (the code is wrong)
- System errors vs JavaScript errors
correct: 1
explain: Operational errors happen in correct programs (file missing, DB down) and should be handled. Programmer errors are bugs and should crash so the process restarts clean. Retrying a bug just reproduces the failure.

Q: A SystemError with `code: 'ENOENT'` means…
- the network connection was refused
- the OS could not find the file you tried to open
- a variable was undefined
correct: 1
explain: ENOENT (Error NO ENTry) is the OS code for a missing file or directory. ECONNREFUSED is the network one. The code property is the stable way to branch on system errors.

Q: Why branch on `err.code` rather than the error message text?
- The code is shorter to type
- The code is a stable machine identifier; the message can vary by locale and Node version
correct: 1
explain: Error messages can be reworded or localized between releases, but err.code (e.g. 'ENOENT') is a stable contract. Branching on the code keeps handlers correct across versions.

Q: An AssertionError firing in production usually signals…
- an operational error worth retrying
- a violated invariant — a programmer error, and the process should restart
correct: 1
explain: Assertions encode invariants that should be impossible to violate in correct code. If one fires, the program is in a state the author didn't think was reachable — a bug, not a transient blip.
```
