You're not handling errors. You're hiding them.
Every app crashes. Every API fails. Every database hiccups at 2am on a Friday.
The difference between a good dev and a great one? What happens next.
Let's roast your error handling — then make it legendary.
🤦 Level 0: The "Trust Me Bro" Dev
No try/catch at all. Just vibes.
const data = await fetchUserData(userId);
console.log(data.profile.name); // 💥 TypeError: Cannot read properties of undefined
The crime: One bad response nukes the entire app. Users see a white screen. You get a 3am Slack ping.
🐣 Level 1: The Junior — "I Googled try/catch"
try {
const data = await fetchUserData(userId);
setUser(data);
} catch (err) {
console.log(err); // 👈 and... that's it. shipped.
}
What's wrong here?
-
console.login production helps nobody — users still see a broken UI - No distinction between a 404 and a 500 — every error is treated the same
- The error disappears into the void (or a DevTools tab nobody has open)
-
errmight benull, a string, or anErrorobject — you're not checking
The mindset: "At least it won't crash." — Yeah, it just silently breaks instead. Cool.
📈 Level 2: The Mid-Level — "I've Been Burned Before"
Now we're thinking. You've seen production fires. You have trust issues with APIs. Good.
2a — Typed errors, real messages
try {
const data = await fetchUserData(userId);
setUser(data);
} catch (err) {
if (err instanceof NetworkError) {
showToast("Connection lost. Check your internet.", "warning");
} else if (err.status === 404) {
showToast("User not found.", "error");
} else {
showToast("Something went wrong. We're on it.", "error");
logger.error("[fetchUserData]", { userId, err }); // 👈 goes to Sentry/Datadog
}
}
✅ Users get useful feedback, not a frozen screen
✅ Engineers get structured logs, not a haystack of console.logs
2b — Undo previous operations (the "atomic mindset")
Imagine you're updating a user's profile and their avatar. Step 1 succeeds. Step 2 fails.
Congrats — your user now has a corrupted half-state.
let previousProfile = null;
try {
previousProfile = await getProfile(userId); // snapshot
await updateProfile(userId, newProfileData); // step 1
await uploadAvatar(userId, newAvatar); // step 2 💥 fails here
} catch (err) {
logger.error("Profile update failed", { err });
// ↩️ Roll back step 1 since step 2 failed
if (previousProfile) {
await updateProfile(userId, previousProfile);
}
showToast("Update failed. Your profile has been restored.", "warning");
}
✅ Users never see broken half-state
✅ Rollback is explicit, not accidental
2c — Wrap it in a clean utility (stop repeating yourself)
Tired of writing try/catch 50 times? Make a helper:
// utils/tryCatch.js
export async function tryCatch(fn, fallback = null) {
try {
const result = await fn();
return [result, null];
} catch (err) {
return [fallback, err];
}
}
// Usage — clean, flat, readable
const [user, err] = await tryCatch(() => fetchUserData(userId));
if (err) {
showToast("Couldn't load user.", "error");
return;
}
setUser(user);
✅ No more deeply nested try/catch pyramids
✅ Forces you to handle the error at call site — can't ignore it
🧠 Level 3: The Senior — "I've Seen Things"
You don't just catch errors. You anticipate them. You build systems that heal themselves.
3a — Retry queue with exponential backoff
Networks are flaky. Don't give up on the first failure.
async function fetchWithRetry(fn, { retries = 3, delay = 500 } = {}) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
const isLast = attempt === retries;
const isRetryable = err.status >= 500 || err instanceof NetworkError;
if (isLast || !isRetryable) throw err; // don't retry 401s or 404s
const backoff = delay * 2 ** (attempt - 1); // 500ms → 1s → 2s
logger.warn(`Attempt ${attempt} failed. Retrying in ${backoff}ms...`);
await sleep(backoff);
}
}
}
// Usage
const data = await fetchWithRetry(() => fetchUserData(userId));
✅ Temporary blips are invisible to users
✅ Smart: retries server errors, not client errors (no point retrying a 401)
3b — Circuit breaker (stop hammering a dead service)
A retry queue is great — unless the whole service is down. Then you're just DDoS-ing a corpse.
class CircuitBreaker {
constructor(threshold = 5, timeout = 30_000) {
this.failures = 0;
this.threshold = threshold;
this.timeout = timeout;
this.state = "CLOSED"; // CLOSED = healthy, OPEN = tripped, HALF_OPEN = testing
this.nextAttempt = Date.now();
}
async call(fn) {
if (this.state === "OPEN") {
if (Date.now() < this.nextAttempt) {
throw new Error("Circuit open — service unavailable");
}
this.state = "HALF_OPEN";
}
try {
const result = await fn();
this.reset();
return result;
} catch (err) {
this.recordFailure();
throw err;
}
}
recordFailure() {
this.failures++;
if (this.failures >= this.threshold) {
this.state = "OPEN";
this.nextAttempt = Date.now() + this.timeout;
logger.error("🔴 Circuit breaker TRIPPED");
}
}
reset() {
this.failures = 0;
this.state = "CLOSED";
}
}
// Usage
const userServiceBreaker = new CircuitBreaker();
const data = await userServiceBreaker.call(() => fetchUserData(userId));
✅ Failing fast is kind — users get an error immediately, not after 10s of retrying
✅ Gives the downstream service a breather to recover
3c — Structured error classes (errors that mean something)
Stop throwing raw strings or generic Errors. Give your errors context.
class AppError extends Error {
constructor(message, { code, statusCode = 500, context = {}, retryable = false } = {}) {
super(message);
this.name = "AppError";
this.code = code;
this.statusCode = statusCode;
this.context = context;
this.retryable = retryable;
this.timestamp = new Date().toISOString();
}
}
// Subclass for specificity
class AuthError extends AppError {
constructor(message, context) {
super(message, { code: "AUTH_ERROR", statusCode: 401, context, retryable: false });
}
}
class ServiceUnavailableError extends AppError {
constructor(service, context) {
super(`${service} is unavailable`, { code: "SERVICE_DOWN", statusCode: 503, context, retryable: true });
}
}
// Throwing
throw new ServiceUnavailableError("UserService", { userId, attempt: 3 });
// Catching
catch (err) {
if (err instanceof AuthError) {
redirectToLogin();
} else if (err instanceof AppError && err.retryable) {
retryQueue.add(err);
} else {
logger.error(err.code, err.context);
showToast("Unexpected error. Engineers notified.");
}
}
✅ Catch blocks can make decisions, not just log and pray
✅ Every error carries its own context — no more guessing what happened
🗺️ The Full Picture
| What you do | Junior | Mid | Senior |
|---|---|---|---|
| Catch errors | ✅ | ✅ | ✅ |
| Inform the user | ❌ | ✅ | ✅ |
| Send to a logger | ❌ | ✅ | ✅ |
| Typed/structured errors | ❌ | ⚠️ partial | ✅ |
| Rollback on failure | ❌ | ✅ | ✅ |
| Retry transient errors | ❌ | ❌ | ✅ |
| Circuit breaker | ❌ | ❌ | ✅ |
| Errors are self-describing | ❌ | ❌ | ✅ |
✅ The Golden Rules
- Never swallow errors silently. A hidden bug is a time bomb.
- Always tell the user something. Frozen UI is worse than an error message.
- Log with context, not just a message — what failed, who triggered it, when.
- Not all errors are equal — 404 ≠ 500 ≠ NetworkError. Handle them differently.
- Retryable ≠ always retry — client errors (4xx) should fail fast.
- Leave the system in a valid state. Roll back or compensate when operations are partial.
-
Your
catchblock is business logic — treat it that way.
"The mark of a great engineer isn't writing code that never fails.
It's writing code that fails gracefully."
Now go fix your try/catches. 🛠️

























