try { ... } catch (err) { console.error(err) } is JavaScript error handling for tutorials. Production applications need more structure — because not all errors mean the same thing, and treating them uniformly is how you end up retrying validation errors and surfacing stack traces to users.
This is about giving errors a real shape so the code that handles them can make smart decisions.
Separate Operational Errors From Bugs
Two fundamentally different categories of errors:
- Operational errors are things that happen in normal operation: a network request fails, a user submits invalid input, a rate limit kicks in, an external service is down. Your code is correct; the world is misbehaving.
- Programmer bugs are mistakes in the code itself: undefined access, type mismatches, infinite loops, broken assumptions. Your code is wrong.
These need different responses. Operational errors get retried, logged with context, surfaced to the user as friendly messages. Bugs need to be reported to your error tracking, not retried (a bug doesn't go away by trying again), not shown to the user beyond a generic apology.
// operational — handle gracefully
try {
await fetchUser(id);
} catch (err) {
if (err instanceof NetworkError) return showRetryButton();
if (err instanceof NotFoundError) return showUserNotFound();
throw err; // unknown — let it propagate
}
If you can't tell which category an error belongs to, you can't handle it correctly.
Custom Errors Add Meaning
A bare Error is hard to handle. A custom error class is pattern-matchable:
class AppError extends Error {
constructor(message, { code, status, cause } = {}) {
super(message, { cause });
this.name = 'AppError';
this.code = code;
this.status = status;
}
}
class NotFoundError extends AppError {
constructor(what) {
super(`${what} not found`, { code: 'not_found', status: 404 });
this.name = 'NotFoundError';
}
}
class ValidationError extends AppError {
constructor(fields) {
super('Validation failed', { code: 'validation_failed', status: 422 });
this.name = 'ValidationError';
this.fields = fields;
}
}
Now catch blocks can use instanceof to react specifically. A generic API error handler can map error classes to HTTP responses without deep if/else chains.
The cause field (standard since ES2022) preserves the original error when wrapping — so you don't lose the underlying network failure when you throw a domain OrderProcessingFailed.
UI Boundaries Need User Messages
Internal errors say INTERNAL_DB_CONSTRAINT_VIOLATION_42. Users see "Something went wrong, please try again." There's a translation layer in between that's worth getting right:
const userMessage = (err) => {
if (err instanceof ValidationError) return 'Please check the highlighted fields.';
if (err instanceof NetworkError) return 'Connection problem. Please try again.';
if (err instanceof NotFoundError) return 'We couldn\'t find what you were looking for.';
if (err instanceof AuthError) return 'Please sign in again.';
return 'Something went wrong. We\'ve been notified.';
};
For React apps, error boundaries catch render-time errors so a single broken component doesn't blank the whole page. For everything else (event handlers, async work, effects), a global rejection handler catches what try/catch missed.
Logging Needs Context Without Secrets
Bad log: Error: Failed. Useless. Good log: structured, with the data needed to reproduce, minus secrets:
function logError(err, context = {}) {
logger.error({
name: err.name,
message: err.message,
stack: err.stack,
code: err.code,
cause: err.cause?.message,
...context, // userId, requestId, route, etc.
});
}
Things that should not be in logs: passwords, full credit card numbers, full tokens, full API keys. Things that absolutely should: request IDs, user IDs (not emails when avoidable), error codes, timestamps. Tools like Sentry handle a lot of this for you, but the same hygiene applies — never trust the SDK to scrub everything.
Retry Is Not A Default
A common mistake: wrap every API call in retry logic "for resilience." This silently retries validation errors, permission denials, and rate limits — none of which fix themselves on retry, all of which waste server time and possibly amplify the problem.
Retry rules:
async function withRetry(fn, { tries = 3, delayMs = 500 } = {}) {
let lastErr;
for (let i = 0; i < tries; i++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (!isRetryable(err)) throw err; // 🚨 don't retry bugs or 4xx
await new Promise(r => setTimeout(r, delayMs * 2 ** i));
}
}
throw lastErr;
}
function isRetryable(err) {
if (err instanceof NetworkError) return true;
if (err.status >= 500 && err.status < 600) return true;
if (err.status === 429) return true;
return false;
}
isRetryable is the policy. Without it, you're guessing.
Pro Tips
- Classify before you handle. Operational vs bug, retryable vs not.
- Use custom error classes —
instanceofis more reliable than string matching. - Preserve
causewhen wrapping errors — keep the trail. - Translate at the UI boundary — internal codes don't belong in user messages.
- Add a global
unhandledrejectionhandler — your safety net for whattry/catchmissed.
Final Tips
The error-handling habit that pays back most: treat errors as a domain-level concern, not a syntax detail. Define your error classes, decide your retry policy, set your logging hygiene, design your user messages — once, up front. Then the catch blocks throughout the codebase have something to reach for.
The opposite — try/catch everywhere with console.error — is what fills production logs with noise that nobody can act on.
Good luck — and may your incidents come with useful logs 👊




