open atlas
↑ Back to track
Node.js, zero to senior NODE · 03 · 01

Error handling: throw, reject, and never swallow

An error is an object with a stack and a code, not a string. Match throw, reject, or an error-first callback to the call style, chain the original with cause, and never swallow a rejection — an unhandled one crashes the process by default.

NODE Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

A payment service logged Error: [object Object] for three weeks. The handler did catch (e) { logger.error("payment failed: " + e) }, and e was a rejected fetch wrapped by a library, so string concatenation flattened it to nothing useful — no stack, no status code, no upstream message. The bug that caused the failures was a one-line timeout misconfiguration, but it stayed invisible because the error had been thrown away at the first catch. The team wasn’t missing a monitoring tool. They were destroying the one object that already carried the answer. In the next few minutes you’ll see exactly why that happens and how to stop it.

An error is an object, not a string

When something fails in Node, the useful artifact is an Error object, and almost every bug in error handling comes from treating it like a string. An Error has a message (the human line), a name ("Error", "TypeError", …), and a stack — a captured trace of where it was created, which is the single most valuable field for debugging. Node adds one more that web code lacks: system and library errors carry a stable code string like ENOENT, ECONNREFUSED, or ERR_INVALID_ARG_TYPE. You branch on code, never on message — messages are prose and change between versions; codes are API.

import { readFile } from "node:fs/promises";

try {
  await readFile("/etc/missing.conf");
} catch (err) {
  console.log(err instanceof Error); // true
  console.log(err.code);             // "ENOENT"  ← branch on this
  console.log(err.message);          // "ENOENT: no such file..."  ← log, don't parse
}

The moment you do "failed: " + err or JSON.stringify(err), you lose it: concatenation calls toString() and yields "Error: message" with no stack, and JSON.stringify returns {} because message and stack are non-enumerable. Log the object itself (logger.error({ err }), or console.error(err)) so the stack survives. This is exactly the bug in the hook: the Error was real and complete, and the + operator threw away everything except a useless prefix.

throw vs reject vs error-first callbacks

Node has three ways failure travels, and they are not interchangeable — each is bound to a call style, and mixing them is a top source of swallowed errors.

Call styleHow failure surfacesHow you catch it
Synchronousthrow errtry / catch (same tick)
Promise / asyncreturns a rejected promiseawait in try/catch, or .catch()
Callback (older core APIs)cb(err, data) — err is arg 1check if (err) first, then return

The trap is that try/catch only catches a synchronous throw in the same tick. A throw inside an asynchronous callback escapes the surrounding try entirely — by the time it runs, the try block has already returned. Likewise, calling an async function without await (or .catch) means its rejection is never observed by your try. The rule: inside async code, await everything you depend on so a rejection becomes a catchable throw; in error-first callbacks, handle err on the first line and return so you don’t fall through into the success path with data undefined.

// throw escapes — the callback runs after the try has exited
try {
  setTimeout(() => { throw new Error("boom"); }, 10); // becomes uncaughtException
} catch (e) { /* never runs */ }

// promise rejection is awaited into a catchable throw
try {
  await fetchUser(id); // rejects → caught here
} catch (e) { /* runs */ }

// error-first: check err first, then return
db.query(sql, (err, rows) => {
  if (err) return cb(err);   // stop here on failure
  cb(null, transform(rows));
});
Why this works

An EventEmitter is the fourth case, and the sharpest. If an emitter fires an 'error' event and there is no 'error' listener attached, Node does not silently drop it — it throws the error, which becomes an uncaughtException and crashes the process. This is deliberate: a stream or socket failing with nobody listening is a bug. Always attach an 'error' handler to streams, sockets, and any emitter that can fail (stream.on("error", …)), even if it only logs.

cause and custom error classes

Catching an error to add context is good; replacing it with a vaguer one is how you lose the stack. Since Node 16.9 the standard fix is the cause option: throw a new, higher-level error while chaining the original underneath, so the trace stays intact.

class ConfigError extends Error {
  constructor(message, options) {
    super(message, options);   // forwards { cause }
    this.name = "ConfigError"; // so logs and instanceof read correctly
  }
}

try {
  await readFile(path);
} catch (err) {
  // wrap with context, keep the original ENOENT and its stack as .cause
  throw new ConfigError(`cannot load config at ${path}`, { cause: err });
}

Two senior habits live here. First, set this.name in a custom class — without it the class name is lost in serialized logs and err.name reads "Error". Second, prefer a small set of typed errors (ValidationError, NotFoundError, ConfigError) that callers can branch on with instanceof or a code property, rather than parsing messages. A modern logger or util.inspect prints the full cause chain automatically, so wrapping costs nothing and buys you the full path from symptom to root.

Pick the best fit

A repository function catches a low-level ECONNREFUSED from the DB driver and wants the API layer to show a clean error without losing the root cause. What does it throw?

The process-level safety net: uncaughtException and unhandledRejection

Some errors escape every local handler — a throw in a stray callback, a promise nobody awaited. Node surfaces these on the process object, and how you treat them separates a robust service from a zombie one.

An unhandled promise rejection — a rejected promise with no .catch or await anywhere — fires process.on("unhandledRejection"), and since Node 15 the default action is to print the error and terminate the process (exit code 1). This is the right default: a rejection you forgot to handle is a bug, and continuing in an unknown state hides it. Do not “fix” it by registering a handler that just logs and swallows — that re-creates the silent-failure problem at global scope.

An uncaught synchronous exception fires process.on("uncaughtException"). The critical, counter-intuitive rule from the Node docs: after an uncaughtException the process is in an undefined state and you must not resume normal operation. A handler is for last-rites only — flush logs, fire a metric, maybe close connections — and then process.exit(1). Let a supervisor (systemd, Kubernetes, PM2) restart a clean process. The pattern below is the production shape: a thin top-level net that records and exits, paired with disciplined local handling everywhere else.

process.on("unhandledRejection", (reason) => {
  console.error("unhandledRejection:", reason); // an Error or any value
  process.exit(1);
});
process.on("uncaughtException", (err) => {
  console.error("uncaughtException:", err);
  // state is unsafe — record, then die. Do NOT keep serving.
  process.exit(1);
});
Quiz

Why does try { setTimeout(() => { throw new Error('x') }) } catch (e) {} NOT catch the error?

Quiz

An unhandled promise rejection occurs in a modern Node service with no unhandledRejection handler registered. What happens by default?

Order the steps

Order the lifecycle of a well-handled failure, from origin to clean recovery:

  1. 1 A low-level op fails and creates an Error with a stack and a code (e.g. ENOENT)
  2. 2 The nearest layer that has context catches it
  3. 3 It re-throws a typed error with { cause: original } to add meaning without losing the stack
  4. 4 A boundary (request handler / job runner) catches the typed error and maps it to a response or retry
  5. 5 Anything that still escapes hits the process net, which logs and exits for a supervisor to restart
Recall before you leave
  1. 01
    Why is concatenating an error into a string (or JSON.stringify-ing it) a bug, and what should you do instead?
  2. 02
    What is the correct response to an uncaughtException, and why is it different from a normal catch?
Recap

In Node, the unit of failure is an Error object, and most error-handling bugs are really data-destruction bugs: concatenating or JSON.stringify-ing an error throws away the stack, the code, and the cause chain, so log the object itself and branch on the stable code, never the prose message. Failure travels three ways bound to three call styles — a synchronous throw caught by try/catch in the same tick, a promise rejection you must await (or .catch) to make catchable, and an error-first cb(err, data) you check and return on the first line — plus the EventEmitter 'error' event, which crashes the process if nobody is listening. When you catch to add context, re-throw a typed error with { cause: original } and set this.name, so you gain a branchable type without losing the trace. And design the global net deliberately: an unhandled rejection crashes by default since Node 15, and after an uncaughtException the process state is undefined — log, then process.exit(1) and let a supervisor restart a clean one, rather than swallowing the failure and serving from a corrupted state. Now when you see [object Object] in a log or a rejection that silently disappears, you’ll know exactly which of these rules was violated and where to fix it.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.