🛡️ Error Handling Strategies
Async code fails in ways synchronous code never does: a network drops mid-request, a server hangs, a promise rejects three call stacks away from where you can see it. This lesson gives you a durable toolkit — error classification, custom error classes, retries, circuit breakers, timeouts, and graceful degradation — so your applications bend under failure instead of breaking.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why asynchronous errors are harder to catch than synchronous ones, and trace the evolution from callbacks to
async/await - Distinguish operational errors from programmer errors and respond to each appropriately
- Design a small hierarchy of custom error classes that carry status, codes, and retry hints
- Implement four resilience patterns: retry with exponential backoff, circuit breaker, timeout/cancellation, and graceful degradation
- Set up global handlers for unhandled rejections as a last-resort safety net
Estimated Time: 45–60 minutes • Difficulty: Intermediate
Hands-on: Build a reusable retry() helper with exponential backoff and jitter, then wrap a flaky fetch with it.
In This Lesson
Why Async Errors Are Different
In synchronous code, an error is like tripping while you carry a plate — you feel it immediately, right where it happens, and a surrounding try/catch can catch it. Asynchronous code is more like running a delivery service across a city: problems are reported back by different drivers, at different times, through different channels — and if a driver simply vanishes, no report arrives at all.
The core difficulty is that a try/catch block only guards the code that runs synchronously inside it. By the time an async callback fires, the original stack frame — and its try/catch — is long gone. That is why a naive try { setTimeout(bad) } catch {} never catches anything.
📖 Key Terms
Rejection: the async equivalent of a thrown error — a promise settles into a rejected state carrying a reason.
Unhandled rejection: a rejected promise that no .catch() or try/catch ever handled. Modern runtimes emit a warning (and Node can crash).
Resilience: an application's ability to keep serving users despite partial failures.
From Callbacks to Async/Await
JavaScript's error-handling story has improved dramatically across three eras. Seeing the progression makes it clear why async/await is the modern default.
The callback era: error-first convention
Early Node.js relied on the "error-first callback" — the first argument is either an error or null. There was no language support; correctness depended entirely on discipline.
const fs = require('node:fs');
fs.readFile('config.json', (err, data) => {
if (err) {
console.error('Error reading file:', err);
return; // easy to forget this early return
}
try {
const config = JSON.parse(data);
console.log('Config loaded:', config);
} catch (parseError) {
console.error('Error parsing JSON:', parseError);
}
});
The problems are structural: errors must be threaded by hand through every layer, a thrown error inside a callback is not caught by any surrounding block, and deep nesting ("callback hell") makes the error paths hard to follow.
The promise era: automatic propagation
Promises let a single .catch() capture failures from anywhere earlier in the chain, and a throw inside a .then() becomes a rejection automatically.
import { readFile } from 'node:fs/promises';
readFile('config.json', 'utf8')
.then(data => {
const config = JSON.parse(data); // a throw here rejects the chain
console.log('Config loaded:', config);
})
.catch(err => {
console.error('Operation failed:', err);
});
The modern era: async/await + try/catch
Async functions let you use the same try/catch you already know, produce cleaner stack traces, and compose naturally with loops and conditionals.
import { readFile } from 'node:fs/promises';
async function loadConfig() {
try {
const data = await readFile('config.json', 'utf8');
const config = JSON.parse(data);
console.log('Config loaded:', config);
return config;
} catch (err) {
console.error('Could not load config:', err);
throw err; // re-throw so callers can decide what to do
}
}
// A caller still needs its own handling for anything loadConfig re-throws.
loadConfig().catch(() => process.exit(1));
✅ Rule of thumb
Prefer async/await with try/catch. Reach for .catch() at the edges — the top-level entry points where nothing above you can handle the error.
Classifying Errors
Not all errors deserve the same response. The most useful split, popularised by the Node.js community, is operational versus programmer errors.
| Aspect | Operational error | Programmer error |
|---|---|---|
| Examples | Timeout, 503, file not found, rate limit | TypeError, calling undefined, bad logic |
| Predictable? | Yes — part of normal operation | No — you did not anticipate it |
| Right response | Retry, fall back, or inform the user | Fail fast, log loudly, fix the code |
| Retryable? | Often | Never — retrying re-runs the bug |
Beyond that split, it helps to name the common categories of async failure so your code can branch on them: network errors (failed requests, dropped connections), timeout errors, API errors (a valid response carrying a 4xx/5xx status), validation errors (bad input or malformed data), and resource errors (missing files, exhausted connection pools).
⚠️ Don't swallow programmer errors. A blanket catch that logs and continues will happily mask a real bug, letting your app run in a corrupted state. Catch what you can meaningfully handle; let the rest bubble up.
Custom Error Classes
A bare Error carries only a message. Real applications need to know an error's HTTP status, a stable machine-readable code, whether it is safe to retry, and a user-facing message that leaks no internals. Subclassing Error gives you all of that, plus instanceof checks.
// A base application error every custom error extends.
class AppError extends Error {
constructor(message, options = {}) {
super(message, { cause: options.cause }); // native error cause
this.name = this.constructor.name;
this.status = options.status ?? 500;
this.code = options.code ?? 'INTERNAL_ERROR';
this.retryable = options.retryable ?? false;
this.userMessage = options.userMessage ?? 'Something went wrong.';
// Trim the constructor frame from the stack (V8 only).
Error.captureStackTrace?.(this, this.constructor);
}
}
class NetworkError extends AppError {
constructor(message, options = {}) {
super(message, {
code: 'NETWORK_ERROR', status: 503, retryable: true,
userMessage: 'A network issue occurred. Check your connection.',
...options,
});
}
}
class TimeoutError extends AppError {
constructor(message, options = {}) {
super(message, {
code: 'TIMEOUT_ERROR', status: 408, retryable: true,
userMessage: 'The request timed out. Please try again.',
...options,
});
this.timeoutMs = options.timeoutMs;
}
}
class ValidationError extends AppError {
constructor(message, fields = {}, options = {}) {
super(message, {
code: 'VALIDATION_ERROR', status: 400, retryable: false,
userMessage: 'Some fields were invalid.',
...options,
});
this.fields = fields; // per-field problems for the UI
}
}
With those in hand, a fetch wrapper can translate raw HTTP responses into meaningful, typed errors — which every later strategy in this lesson keys off of:
async function fetchUser(userId) {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
if (response.status === 404) {
throw new ValidationError(`User ${userId} not found`, { userId: 'not found' });
}
if (response.status >= 500) {
throw new NetworkError(`Server error fetching user ${userId}`, { status: response.status });
}
throw new AppError(`Failed to fetch user ${userId}`, { status: response.status });
}
return response.json();
}
💡 Why this.name = this.constructor.name?
Without it, subclass instances still report name === 'Error', so log lines read "Error:" instead of "NetworkError:". Setting it from the constructor keeps the label correct for every subclass automatically.
Resilience Strategies
Classifying errors tells you what happened. These four patterns tell you what to do about it.
Strategy 1 — Retry with exponential backoff
Transient failures (a blip, a momentarily overloaded server) often succeed on a second try. But retrying immediately, in a tight loop, can make things worse — a thundering herd of clients all retrying at once. Exponential backoff grows the delay after each attempt; jitter adds randomness so clients don't synchronise.
async function retry(fn, options = {}) {
const {
maxRetries = 3,
initialDelay = 1000,
backoffFactor = 2,
maxDelay = 30_000,
shouldRetry = (error) => error.retryable === true,
} = options;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn(attempt);
} catch (error) {
lastError = error;
if (attempt >= maxRetries || !shouldRetry(error)) break;
// Exponential growth, capped, with +/-20% jitter.
const base = initialDelay * backoffFactor ** attempt;
const delay = Math.min(maxDelay, base * (0.8 + Math.random() * 0.4));
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
// Usage: only retry server errors, never a 400.
const data = await retry(
async () => {
const res = await fetch('/api/report');
if (!res.ok) {
throw new AppError(`HTTP ${res.status}`, { retryable: res.status >= 500 });
}
return res.json();
},
{ maxRetries: 5 }
);
Strategy 2 — Circuit breaker
If a dependency is truly down, retrying every request just piles pressure on a struggling service and slows your app too. A circuit breaker watches the failure rate and, once a threshold is crossed, "trips open" — failing fast for a cool-down window instead of even trying. After the window it goes half-open to test the waters with a single request.
class CircuitBreaker {
constructor({ failureThreshold = 5, resetTimeout = 30_000 } = {}) {
this.failureThreshold = failureThreshold;
this.resetTimeout = resetTimeout;
this.state = 'CLOSED';
this.failureCount = 0;
this.nextAttempt = 0;
}
async execute(fn) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new AppError('Circuit breaker is open', { code: 'CIRCUIT_OPEN', retryable: false });
}
this.state = 'HALF_OPEN'; // time to test the waters
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failureCount++;
if (this.state === 'HALF_OPEN' || this.failureCount >= this.failureThreshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.resetTimeout;
}
}
}
Strategy 3 — Timeouts and cancellation
Never let an async operation hang forever. The modern approach uses AbortController: AbortSignal.timeout(ms) produces a signal that aborts on its own, and fetch accepts a signal so the underlying request is genuinely cancelled — not just ignored.
async function fetchWithTimeout(url, { timeoutMs = 5000, ...options } = {}) {
// AbortSignal.timeout is built in to modern browsers and Node 18+.
const signal = AbortSignal.timeout(timeoutMs);
try {
const response = await fetch(url, { ...options, signal });
if (!response.ok) throw new AppError(`HTTP ${response.status}`, { status: response.status });
return response.json();
} catch (error) {
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
throw new TimeoutError(`Request to ${url} timed out`, { timeoutMs, cause: error });
}
throw error;
}
}
⚠️ A timeout without cancellation is a leak
Racing a promise against a setTimeout only makes your code stop waiting; the original request keeps running in the background, holding a socket and eventually resolving into the void. Wiring an AbortSignal into fetch actually tears the work down.
Strategy 4 — Graceful degradation
When the ideal path fails, a good app offers a lesser one instead of a blank error screen. Try alternatives in order, from richest to cheapest, and only give up if every option fails.
async function withFallbacks(fns) {
const errors = [];
for (const fn of fns) {
try {
return await fn();
} catch (error) {
errors.push(error); // remember why each option failed
}
}
throw new AggregateError(errors, 'All fallbacks failed');
}
// Load a profile: live API, then cache, then a safe default.
const profile = await withFallbacks([
() => fetchWithTimeout(`/api/users/${id}/profile`),
() => caches.match(`/api/users/${id}/profile`).then(r => r.json()),
() => ({ id, name: 'Guest', avatar: '/img/default-avatar.png', partial: true }),
]);
Global Safety Nets
Even careful code misses an edge case. A global handler catches rejections that slipped through, so you can log them to a monitoring service and show a friendly message instead of a silent failure.
// In the browser:
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled rejection:', event.reason);
reportToMonitoring(event.reason); // e.g. Sentry
event.preventDefault(); // suppress the default console error
});
// In Node.js:
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
reportToMonitoring(reason);
// For a truly unknown/programmer error, it is safest to exit and let
// your process manager restart a clean instance.
});
💡 Safety net, not a strategy
Global handlers are the fire alarm, not the fire prevention. Handle errors at the source wherever you can; rely on the global handler only for the ones you genuinely could not foresee.
Hands-on Exercise
🏋️ Build a resilient fetch
Objective: Combine several patterns from this lesson into one small, reusable utility.
Instructions:
- Write a
retry(fn, options)helper with exponential backoff and jitter (start from the version above). - Write a
fetchWithTimeout(url, ms)that aborts viaAbortSignal.timeout. - Combine them:
resilientFetch(url)should retry up to 3 times, each attempt capped at a 4-second timeout, retrying only on 5xx or timeout. - Test it against a deliberately flaky endpoint — e.g. a small handler that returns 503 the first two calls, then 200. Log each attempt so you can watch the backoff grow.
💡 Hint
Have the inner function throw an error whose retryable flag you set from the status (res.status >= 500), and make your shouldRetry also accept a TimeoutError. That keeps the retry logic declarative.
✅ Sample solution
async function resilientFetch(url, { retries = 3, timeoutMs = 4000 } = {}) {
return retry(
async () => {
const signal = AbortSignal.timeout(timeoutMs);
const res = await fetch(url, { signal }).catch(err => {
throw new TimeoutError('Request timed out', { cause: err });
});
if (!res.ok) {
throw new AppError(`HTTP ${res.status}`, { retryable: res.status >= 500 });
}
return res.json();
},
{
maxRetries: retries,
shouldRetry: (error) =>
error.retryable === true || error instanceof TimeoutError,
}
);
}
// Watch the backoff in the console:
resilientFetch('/api/flaky')
.then(data => console.log('Success:', data))
.catch(err => console.error('Gave up:', err.message));
🎯 Quick Quiz
Question 1: Why is jitter added to exponential backoff?
Question 2: A user submits a form and the server responds 400 Bad Request because a field is invalid. How should your retry logic treat it?
Question 3: What does a circuit breaker in the OPEN state do?
Best Practices
✅ Do
- Use custom error classes that carry
status,code, and aretryablehint. - Set a timeout on every network call, backed by a real
AbortSignal. - Retry only genuinely transient errors, with backoff and jitter.
- Preserve the original error via the native
causeoption when wrapping. - Give users an actionable, non-technical message; log the technical detail separately.
⚠️ Don't
- Wrap code in
try { } catch { }that silently swallows every error. - Retry non-idempotent writes (like a payment) without a safeguard such as an idempotency key.
- Rely on the global handler as your primary strategy.
- Leak stack traces, SQL, or secrets into a user-facing message.
Summary & Quiz
🎉 Key Takeaways
- Async errors escape ordinary
try/catch;async/awaitbrings them back under a familiar syntax. - Separate operational errors (recover/retry) from programmer errors (fail fast, fix).
- Custom error classes let every later strategy branch on status, code, and retryability.
- Four resilience patterns cover most needs: retry + backoff, circuit breaker, timeout/cancellation, and graceful degradation.
- Global unhandled-rejection handlers are a safety net, not a substitute for handling errors at the source.
📚 Further Reading
- MDN — Control flow and error handling
- MDN — AbortController & AbortSignal
- Node.js Best Practices — Error Handling
- Martin Fowler — Circuit Breaker
🚀 What's Next?
Now that a single operation can fail gracefully, the next lesson — Concurrent Operations Management — scales this up to many operations at once: running them in parallel, racing them, limiting concurrency, and deciding what "failure" means when five requests fire together.
🎉 Well done!
Your async code can now bend under failure instead of snapping. Let's make it fast and resilient.