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. π οΈ

Top comments (0)