Node.js / Intermediate: application boundaries / Lesson 8
Error boundaries and cancellation
Preserve useful causes, classify expected failures, and make cancellation part of the operation contract.
On this page
Lesson goals
- Explain the concepts and trade-offs behind error boundaries and cancellation.
- Predict and verify both examples, including their outputs and cleanup behavior.
- Diagnose the three common failures and apply the worked solutions to related inputs.
Failures need an owner
Every asynchronous operation needs a caller that observes its outcome. An HTTP framework does not automatically understand every detached promise, and an async EventEmitter listener requires deliberate rejection handling. Distinguish a rejected command, a temporary dependency failure, a canceled request, and a programming defect. Their user responses, logs, and retry policies differ. Put an error boundary where that classification can become an appropriate response or exit status. Lower-level helpers should add relevant context without pretending that they can decide the whole applicationโs recovery policy.
Use stable classification
An Error provides a message and stack, while application classes or codes provide stable classification. Do not choose response status by searching English text inside a message. Preserve an underlying error with cause when wrapping it, then expose only a safe public message. The first example distinguishes malformed stored data from its original parsing failure. Detailed internal context belongs in controlled diagnostic records, not in a public JSON response. Avoid repeatedly wrapping the same failure at every layer because a long stack of vague messages can obscure the useful cause.
Translate errors once
A boundary can map a known error code to a documented client response and treat unknown failures as internal errors. It should record enough context to investigate, using a request identifier and safe fields, then release request-owned resources. If an HTTP response has already started, the boundary cannot promise a new status and body; it may need to terminate the connection. Never swallow an error merely to keep a process appearing healthy. After an uncaught exception, continuing normal service can preserve corrupted state, so plan controlled termination and external restart.
class DataError extends Error {
constructor(cause) { super('Stored data is invalid', { cause }); this.code = 'BAD_DATA'; }
}
function decode(text) {
try { return JSON.parse(text); } catch (cause) { throw new DataError(cause); }
}
try { decode('{'); }
catch (error) {
console.log(error.code, error.cause.name);
console.log('Public response: internal_error');
}
BAD_DATA SyntaxError
Public response: internal_error
The public message avoids exposing the stored content; the diagnostic error still preserves the parsing cause.
Propagate cancellation
A caller-owned AbortController lets supported dependencies observe that the result is no longer needed. The second example immediately cancels a promise-based timer and classifies AbortError as cancellation. Passing a signal is only useful if every relevant layer forwards and observes it. Stop retrying when the caller cancels. Clear request-specific timers and detach listeners after completion to avoid memory retention. Cancellation can race with successful completion, so design cleanup to be safe when called more than once and avoid treating cancellation as proof that a remote side effect never happened.
import { setTimeout as delay } from 'node:timers/promises';
async function load({ signal }) {
await delay(1000, undefined, { signal });
return 'loaded';
}
const controller = new AbortController();
const result = load({ signal: controller.signal });
controller.abort();
try { await result; }
catch (error) {
if (error.name !== 'AbortError') throw error;
console.log('request canceled; no retry');
}
request canceled; no retry
The signal reaches the actual waiting operation, so cancellation releases its timer rather than merely ignoring the result.
Retry only with a reason
Retries amplify load and may duplicate writes. Retry only failures known to be transient, within a bounded attempt count and overall deadline, with backoff and jitter. A validation failure usually needs corrected input rather than another identical attempt. A timed-out payment may already have succeeded remotely, so a safe retry needs an idempotency contract or reconciliation. Test the number of attempts and cancellation behavior, not just the final returned value. Recovery is a business decision constrained by resource budgets, not a catch block that repeats everything until it appears successful.
Common errors and fixes
A catch block hides every failure
- Why it happens
- All errors were converted to a success-shaped default.
- How to fix it
- Handle known cases and rethrow unexpected errors.
- Prevent it next time
- Assert failure classification in tests.
A canceled request keeps retrying
- Why it happens
- Retry code did not observe the caller signal.
- How to fix it
- Check cancellation before attempts and during waits.
- Prevent it next time
- Give retries one shared deadline and signal.
An upstream write occurs twice
- Why it happens
- A timeout was assumed to mean no side effect occurred.
- How to fix it
- Use idempotency keys or reconcile the remote result.
- Prevent it next time
- Define retry semantics before enabling automatic retries.
Check your understanding
1. A dependency returns a permanent invalid-input error. Should a generic retry helper repeat it three times?
Show worked solution
No. Classify it as nonretryable and return a useful correction immediately. Repeating identical invalid input wastes capacity and delays the caller. Test that the dependency is invoked once and that the boundary produces the documented client-facing error.
2. How should a wrapper preserve a low-level connection failure without exposing it to users?
Show worked solution
Create an application error with a stable code and the original Error as cause. Log only approved diagnostic context under a request ID, and return a generic dependency-unavailable message. This preserves investigation details while keeping the public contract stable.
Key takeaways
- Every asynchronous operation needs a caller that observes its outcome.
- An Error provides a message and stack, while application classes or codes provide stable classification.
- Retries amplify load and may duplicate writes.