---
title: 'Error Handling Strategy'
source: 'https://academia.sh/en/courses/nodejs/error-handling-strategy'
course: 'The Node.js Runtime'
language: en
updated: '2026-08-17T18:09:51+00:00'
license: 'CC BY-SA 4.0'
---

# Error Handling Strategy

The distinction between operational errors and programmer errors, chaining error causes, the forms of asynchronous errors that escape catching, and the correct use of last-resort hooks.

The previous lesson said a configuration error should stop the process from coming
up. Not every error occurring while running deserves the same treatment: a
request's malformed body should not shut down the service, but a corrupted
in-memory state should.

This lesson's question: which error gets caught, and which one ends the process?
The distinction rests on a single criterion, and the whole strategy follows from it.

## Two Classes of Error

An **operational error** is a condition a correctly written program expects to meet
in its running environment: a missing file, an unreachable network, a client that
sent invalid data, a full disk. Not the program's fault — anticipated and handled.

A **programmer error** is the code violating its own contract: accessing a field on
an undefined value, forgetting a required argument, giving an object where a number
is expected. Unanticipated, and it shows the assumptions about the program's state
at that moment were wrong.

The rule: **an operational error is handled, a programmer error is not.** Catching
the second and continuing means running on with a corrupted state — a failure of
unknown duration and much harder to diagnose.

For the distinction to be visible in code, operational errors are marked with their
own type:

```js
// error-types.mjs
export class OperationalError extends Error {
  constructor(message, { code, status = 500, cause } = {}) {
    super(message, { cause });
    this.name = 'OperationalError';
    this.code = code;
    this.status = status;
    this.operational = true;
  }
}

export function isOperational(error) {
  return error instanceof OperationalError && error.operational === true;
}
```

## Chaining the Cause

An error from a lower layer gets translated moving up to a higher one: the higher
layer throws an error that speaks its own language, without losing the real cause.
The `cause` option builds that link.

```js
// try-errors.mjs
import { readFile } from 'node:fs/promises';
import { OperationalError, isOperational } from './error-types.mjs';

async function readMeasurements(path) {
  try {
    return await readFile(path, 'utf8');
  } catch (error) {
    if (error.code === 'ENOENT') {
      throw new OperationalError(`measurement file missing: ${path}`, {
        code: 'MEASUREMENT_FILE_MISSING', status: 503, cause: error,
      });
    }
    throw error;
  }
}

for (const path of ['measurements.ndjson', 'missing.ndjson']) {
  try {
    const text = await readMeasurements(path);
    console.log(`${path}: ${text.trim().split('\n').length} lines`);
  } catch (error) {
    console.log(`${path}: operational = ${isOperational(error)}, code = ${error.code}, status = ${error.status}`);
    console.log(`  root cause: ${error.cause.code}`);
  }
}

// Programmer error: contract violation, never caught and swallowed
try {
  null.value;
} catch (error) {
  console.log(`programmer error: operational = ${isOperational(error)}, type = ${error.constructor.name}`);
}
```

```sh
node try-errors.mjs
```

```
measurements.ndjson: 12 lines
missing.ndjson: operational = true, code = MEASUREMENT_FILE_MISSING, status = 503
  root cause: ENOENT
programmer error: operational = false, type = TypeError
```

Three details matter.

Only the recognized error code is translated; everything else rethrows as is.
Translating every error into a single type hides unknown failures inside a known
category.

The translated error carries its own code. The caller decides without parsing the
message text; the code stays fixed even if the message changes.

The real cause stays reachable — reaching the system-call error at the bottom of
the chain is the shortest path to the failure during diagnosis.

## The Counterpart in the Service

The error handler in the HTTP Server lesson reported every error as 400. Once the
distinction is established, the correct mapping can be made:

```js
// error-server.mjs
import { createServer } from 'node:http';
import { OperationalError, isOperational } from './error-types.mjs';
import { recordFromLine } from './summarizer.mjs';

function errorResponse(error, response) {
  if (isOperational(error)) {
    response.writeHead(error.status, { 'content-type': 'application/json' });
    response.end(JSON.stringify({ error: error.message, code: error.code }) + '\n');
    return;
  }
  // Programmer error: no detail goes out, the full record stays inside
  process.stderr.write(`programmer error: ${error.stack.split('\n')[0]}\n`);
  response.writeHead(500, { 'content-type': 'application/json' });
  response.end(JSON.stringify({ error: 'internal error', code: 'INTERNAL_ERROR' }) + '\n');
}

const server = createServer(async (request, response) => {
  try {
    if (request.url === '/measurement') {
      const chunks = [];
      for await (const chunk of request) chunks.push(chunk);
      const body = Buffer.concat(chunks).toString('utf8');
      try {
        recordFromLine(body.trim());
      } catch (error) {
        throw new OperationalError('invalid measurement record', {
          code: 'INVALID_RECORD', status: 400, cause: error,
        });
      }
      response.writeHead(201, { 'content-type': 'application/json' });
      response.end('{"received":1}\n');
      return;
    }
    if (request.url === '/crash') {
      null.value;                       // deliberate programmer error
    }
    throw new OperationalError('not found', { code: 'ROUTE_NOT_FOUND', status: 404 });
  } catch (error) {
    errorResponse(error, response);
  }
});

server.listen(8791, '127.0.0.1', () => console.log('listening'));
```

Three requests are sent with the server running:

```sh
curl -s -X POST --data-binary 'broken' -w 'status: %{http_code}\n' http://127.0.0.1:8791/measurement
```

```
{"error":"invalid measurement record","code":"INVALID_RECORD"}
status: 400
```

```sh
curl -s -w 'status: %{http_code}\n' http://127.0.0.1:8791/missing
```

```
{"error":"not found","code":"ROUTE_NOT_FOUND"}
status: 404
```

```sh
curl -s -w 'status: %{http_code}\n' http://127.0.0.1:8791/crash
```

```
{"error":"internal error","code":"INTERNAL_ERROR"}
status: 500
```

The last call printed this line in the server's terminal:

```
programmer error: TypeError: Cannot read properties of null (reading 'value')
```

The operational error's message goes to the client, the programmer error's does
not — not a habit of hiding things, a decision resting on two reasons: the stack
trace and internal message leak file paths and code structure; and there is nothing
the client can do about it anyway — the only meaningful response to a 500 is to
retry or report it.

## Where Asynchronous Errors Escape Catching

`try`/`catch` covers only its own synchronous body and awaited promises. An error
thrown inside a callback falls outside that scope.

```js
// uncaught.mjs
import { readFile } from 'node:fs/promises';

// try/catch does not catch an error deferred to a callback
try {
  setTimeout(() => { throw new Error('thrown inside a callback'); }, 0);
} catch (error) {
  console.log('this line never runs');
}

// Unhandled rejection
readFile('missing.ndjson', 'utf8');

console.log('sync flow done');
```

```sh
node uncaught.mjs
```

The output's first line is `sync flow done`; then the uncaught error prints and the
process ends with a nonzero code. `this line never runs` never prints: `setTimeout`
returned immediately, and the error was thrown much later, in the loop's timers
phase — by then the `try` block had long since finished.

A second problem in the same file is sneakier. Because the `readFile` call is not
awaited, the rejection of the promise it returns is never handled anywhere. This is
an **unhandled rejection**, and its default behavior is to end the process:

```js
// rejection.mjs
import { readFile } from 'node:fs/promises';

readFile('missing.ndjson', 'utf8');     // returned promise is not awaited, its error goes unhandled
console.log('sync flow done');
```

```sh
node rejection.mjs > /dev/null; echo "exit code: $?"
```

```
exit code: 1
```

This looks harsh, but it is correct: a silently swallowed rejection leads to data
being thought written when it was not. Rule: **every call that returns a promise is
either awaited or has its rejection explicitly handled.**

## Last-Resort Hooks

Two hooks put uncaught errors into the program's own hands:

```js
// last-resort.mjs
process.on('uncaughtException', (error, origin) => {
  process.stderr.write(`fatal: ${error.message} (origin: ${origin})\n`);
  process.exit(1);            // state is considered corrupted: restart
});

process.on('unhandledRejection', (reason) => {
  process.stderr.write(`unhandled rejection: ${reason.message ?? reason}\n`);
  process.exit(1);
});

setTimeout(() => { throw new Error('late error'); }, 0);
console.log('hook installed');
```

```sh
node last-resort.mjs; echo "exit code: $?"
```

```
hook installed
fatal: late error (origin: uncaughtException)
exit code: 1
```

The only legitimate use of these hooks is recording the failure before the process
dies. Installing the hook and continuing to run breaks the runtime's default
behavior and lets it keep going in an unknown state.

No long work happens inside the hook. The process is already in a suspect state;
starting an asynchronous log write there gives no guarantee it will complete. A
short, synchronous line and exiting is safest.

Keeping the service alive after the process dies is not the process's own job — an
external supervisor that restarts it handles that, taken up in the Process
Management and Resilience lesson.

## Summary

- An operational error is an anticipated running condition and is handled; a
  programmer error is a contract violation and is not.
- Operational errors are marked with their own type and carry a code field; the
  caller does not parse the message text to decide.
- When an error is translated, `cause` preserves the real reason; only recognized
  conditions are translated, the rest is rethrown as is.
- An error thrown inside a callback and the rejection of an unawaited promise fall
  outside `try`/`catch`'s scope; the second's default result is the process ending.
- Last-resort hooks are used only to record and exit; installing the hook and
  continuing means proceeding in an unknown state.

## Next Step

Recording errors came up a few times, but where and in what form was never
settled. A free-text message is no use to someone searching for the failure —
what's needed is a record format supporting searching, filtering, and counting.
The next lesson builds structured logging and adds level management.
