JavaScript Error Handling Best Practices: Where to Catch
Catch JavaScript errors at boundaries, where a failure becomes an outcome. Expected failures versus bugs, rethrow versus wrap, retries and result objects.
- Course: JavaScript study plan
- Module: Errors and error handling
- Kind: Lesson
- Reading time: 13 min
- Runtime: Node 16
Where should you catch errors in JavaScript?
Catch errors at boundaries — the places where a failure means something to someone: an HTTP handler that turns it into a status code, a CLI's main function, a UI event handler, a job runner, one item of a batch. Code in between should catch only to recover, to add context by wrapping with cause, or to clean up in finally; otherwise let the error propagate and log it once, at the boundary.
Lesson
Knowing the syntax of try/catch is the easy part; knowing where to put one is the skill. Catch too early and you swallow information the caller needed; catch too late and one bad record aborts a batch of ten thousand; never catch and the process dies on a typo in a config file. The working rule: errors are handled at boundaries — the places where a failure has a meaning to someone (a request, a job, a file, a UI action) — and everything between the throw and the boundary lets them pass, adding context at most. This lesson gives that rule its details: expected failures versus bugs, rethrow versus wrap, per-item recovery, retries, result objects, and the two process-level nets.
Two kinds of failure
Expected failures are part of the domain: invalid input, a missing record, a network timeout, a file that is not there. They will happen in production every day; code must handle them and users must see something sensible. Bugs are defects: undefined.x, a wrong argument type, an impossible state. Handling them means finding them — logging with a stack trace and, usually, failing the current operation loudly so the defect is noticed. The most common error-handling mistake is treating both the same: a catch that shows "something went wrong" for a TypeError hides a bug behind a friendly message forever.
The type system from the previous lesson is how you tell them apart: err instanceof AppError (or a code) is expected; anything else is a bug.
Boundaries
A boundary is where an error stops being a control-flow event and becomes an outcome: an HTTP handler turning it into a status code, a CLI's main function printing a message and exiting non-zero, a UI event handler showing a toast, a job runner marking the job failed, a batch loop recording one item's failure. Catch there. Between the throw site and the boundary, functions should not catch unless they can do one of three things:
- Recover — try an alternative that satisfies the caller (a cache miss falls through to the database; a missing optional file yields defaults).
- Add context — wrap with
{ cause }and information only this layer has (which file, which record id, which step), then rethrow. - Clean up — with
finally, which is not catching at all.
A catch that logs and rethrows at every layer produces the same error in the log five times; log once, at the boundary.
Rethrow versus wrap
Rethrow (throw err) when the caller would handle the original type just as well — most of the time. Wrap when crossing an abstraction: a ConfigError("config.json invalid", { cause: syntaxError }) tells the operator what to fix; the raw SyntaxError: Unexpected token } does not. Wrapping also converts a third-party library's error types into yours, so callers depend on your API rather than on the library's. Keep the cause.
Per-item recovery
const outcomes = records.map((r) => {
try { return { ok: true, value: importRecord(r) }; }
catch (err) {
if (!(err instanceof ValidationError)) throw err; // a bug aborts the batch — loudly
return { ok: false, id: r.id, error: err.message }; // an expected failure is recorded and we move on
}
});
A batch has two natural boundaries: the item (expected failures are recorded, processing continues) and the batch (a bug stops everything). Note the instanceof guard — the pattern is not "catch everything per item".
Retries
Retry only failures that are transient (timeouts, 503s, connection resets) and only operations that are idempotent (safe to repeat). A validation error will fail identically every time; retrying a non-idempotent payment charges twice. Bound the attempts, back off between them, and wrap the final failure with the attempt count and the last cause:
function withRetry(fn, { attempts = 3, isTransient }) {
let last;
for (let i = 1; i <= attempts; i++) {
try { return fn(i); }
catch (err) {
if (!isTransient(err)) throw err;
last = err;
}
}
throw new Error(`gave up after ${attempts} attempts`, { cause: last });
}
(Asynchronous retries with real delays are the same shape with await — module 8.)
Result objects instead of exceptions
Some failures are so ordinary that throwing is noise: a parser that rejects half its inputs, a lookup that often finds nothing, a validator that reports many problems at once. Return a value that says so:
function parsePort(text) {
const n = Number(text);
return Number.isInteger(n) && n > 0 && n < 65536 ? { ok: true, value: n } : { ok: false, error: `invalid port: ${text}` };
}
const r = parsePort(input);
if (!r.ok) return usage(r.error);
Discriminated results ({ ok: true, value } | { ok: false, error }) make failure visible in the signature, compose with map/filter, and collect naturally (a list of all validation errors rather than the first). Exceptions remain right for failures that are exceptional, cross many layers, or would otherwise need if (!r.ok) return r on every line. Neither is "correct"; mixing them by layer is normal — parsers and validators return results, I/O and invariants throw.
Do not swallow
catch (e) {} and catch (e) { console.log(e) } (then continuing as if it worked) are the two forms of the worst bug: the program proceeds in a state it never expected, and the failure surfaces later somewhere unrelated. If you truly cannot handle an error, let it propagate. If a failure genuinely does not matter (best-effort telemetry, optional cache warm-up), say so in a comment and keep the try block to the single call that may fail.
The last nets
process.on("uncaughtException", handler) and process.on("unhandledRejection", handler) catch what nothing else did. They are for logging and exiting cleanly — flush the log, close connections, process.exit(1) — not for continuing: after an uncaught exception the process may be in an inconsistent state, and Node's documentation says to restart. A supervisor (systemd, a container orchestrator, PM2) restarts the process; that is the recovery. In a browser, window.onerror/unhandledrejection are the equivalents, used to report to telemetry.
Messages that help
A good message says what failed, which (the id, path or field), and if possible what would fix it: config db.port must be 1–65535, got 70000 rather than invalid config. Include values, never secrets. Messages are for developers and operators; the boundary decides what a user sees.
Common mistakes
- One
try/catcharound everything, showing a generic message for bugs and expected failures alike. - Catching at every layer to log — five copies of one error.
- Catching per item without a type guard, so a bug in the loop body silently fails every record.
- Retrying non-transient or non-idempotent operations, or without a bound.
- Continuing after
uncaughtException. - Messages without the identifying value.
Interview angle
- "Where should errors be caught?" At boundaries — where a failure becomes an outcome; intermediate layers recover, add context, or clean up, otherwise let it pass.
- "Expected failure versus bug?" Domain failures are handled and shown; bugs are logged with a stack and fail loudly;
instanceof AppError/codeseparates them. - "When would you return a result object instead of throwing?" Ordinary, frequent, or multiple failures — parsers and validators — where the signature should show it.
- "When is a retry appropriate?" Transient failure, idempotent operation, bounded attempts with backoff.
- "What should
uncaughtExceptiondo?" Log, clean up, exit; let a supervisor restart.
Key takeaways
- Separate expected failures (handle, show) from bugs (log with stack, fail loudly) by type or code.
- Catch at boundaries; in between, recover, wrap-with-cause, or
finally— otherwise propagate. - Per-item recovery needs a type guard; a bug should still abort the batch.
- Retry only transient + idempotent, bounded, with backoff; wrap the final failure.
- Result objects for ordinary failures; exceptions for exceptional ones; never swallow;
uncaughtExceptionmeans exit.
Common questions
Should you rethrow or wrap an error in JavaScript?
Rethrow with throw err when the caller can handle the original error just as well, which is most of the time. Wrap with new Error(message, { cause: err }) when crossing an abstraction, to add context only this layer knows — which file, which record — or to turn a library's errors into your own.
When should you return a result object instead of throwing?
When failure is ordinary, frequent or plural: parsers that reject half their input, lookups that often find nothing, validators that report many problems at once. A result like { ok: false, error } makes failure visible in the signature; exceptions stay right for rare failures that cross many layers.
When is it safe to retry a failed operation?
Only when the failure is transient — a timeout, a 503, a connection reset — and the operation is idempotent, so repeating it cannot charge a payment twice. Bound the attempts, back off between them, and wrap the final failure with the attempt count and the last cause.
What should an uncaughtException handler do in Node.js?
Log the error, clean up — flush logs, close connections — and exit with process.exit(1). It must not keep the process running: after an uncaught exception the state may be inconsistent, so a supervisor such as systemd, PM2 or a container orchestrator restarts it instead.
Why is an empty catch block bad practice?
catch (e) {} swallows every error, including bugs such as a TypeError, and the program carries on in a state it never expected, so the failure surfaces later somewhere unrelated. If you cannot handle an error, let it propagate; if it truly does not matter, keep the try to that one call and say why in a comment.
Exercises
A batch with two boundaries
Records arrive one per line: ok <id>, invalid <id> <reason> (the starter's importRecord throws a ValidationError) or crash <id> (it throws a TypeError — a bug). Process every record inside an item-level try/catch that recovers only from ValidationError (print skipped <id>: <message>), rethrowing anything else; successes print imported <id>. Wrap the loop in a batch-level try/catch: on normal completion print done: imported=<n> skipped=<m>; when a bug escapes print fatal: <name>: <message> - batch aborted after <records seen> records (imported=<n> skipped=<m>).
Example: ok r1, invalid r2 bad, crash r3, ok r4 →
imported r1
skipped r2: r2: bad
fatal: TypeError: unexpected null record at r3 - batch aborted after 3 records (imported=1 skipped=1)
r4 was never processed — a bug stops the batch, an expected failure does not.
Bounded retry with a wrapped final failure
Implement withRetry(fn, { attempts, isTransient }): call fn(i) for i = 1…attempts; on success print attempt <i>: ok and return the value; on a transient error print attempt <i>: failed (transient) and try again; on a non-transient error print attempt <i>: failed (permanent) - not retrying and rethrow; after the last attempt throw Error("gave up after <attempts> attempts", { cause: lastError }). Input attempts failures kind: the flaky function fails for the first failures calls with a TransientError("attempt i timed out") (or PermanentError("attempt i rejected") when kind is permanent), then returns ok on attempt i. Print result=<value> or failed: <cause chain joined by " <- ">.
Example: 3 2 transient →
attempt 1: failed (transient)
attempt 2: failed (transient)
attempt 3: ok
result=ok on attempt 3
3 5 transient → three failures then failed: Error: gave up after 3 attempts <- TransientError: attempt 3 timed out.
In this module: Errors and error handling
- throw, try, catch, finally — the mechanics, precisely
- The built-in error types, and designing your own
- Where to catch — strategies, boundaries and the result-object alternative (this lesson)
- Validation, guard clauses and assertions — failing early with a useful message
- Reading stack traces and decoding the errors you will see most
- Checkpoint — Errors
← The built-in error types, and designing your own · Validation, guard clauses and assertions — failing early with a useful message →