Error Handling in Async Code
The Silent Killer
LinkedIn Hook
Every async bug that ever hit your production logs at 3am started the same way — someone forgot a
try/catch, or fired a Promise without a.catch(), and the error disappeared into the void. Until it didn't.In Node.js 15+, an unhandled rejection crashes the process by default. In browsers, it bypasses your UI error boundary and shows up as a raw console warning. In both, the user sees a frozen screen, a spinner that never stops, or — worst of all — a silent failure where a "save" button did nothing but claimed it did.
This lesson walks through every error-handling pattern you actually need:
try/catcharoundawait,.catch()at the end and in the middle of a chain, a Go-style[err, result]utility to killtry/catchboilerplate, global handlers for the last line of defense, and the "floating Promise" anti-pattern — the single biggest source of silent async bugs in real codebases.Get this right and async errors become predictable. Get it wrong and every bug is a ghost.
Read the full lesson -> [link]
#JavaScript #InterviewPrep #AsyncJS #ErrorHandling #Frontend #CodingInterview #WebDevelopment
What You'll Learn
- Every pattern for handling async errors —
try/catch, chain.catch(), mid-chain recovery, and Go-style tuples - What a "floating Promise" is and why it's the #1 cause of silent bugs in async code
- How to set up global unhandled-rejection handlers as a last-resort safety net in browser and Node
What Happens If You Forget try/catch with await
This is the silent killer in async code. Imagine a safety net with a hole in it:
async function riskyFunction() {
const data = await fetch("https://api.fake-url.com/data");
// If fetch rejects, this function returns a rejected Promise
// If nobody catches it...
return data.json();
}
// PROBLEM: No .catch() on the call
riskyFunction();
// Uncaught (in promise) TypeError: Failed to fetch
// In Node.js, this can crash your process!
The .catch() Chaining Pattern
// Pattern 1: .catch() at the end of a chain
fetchUser()
.then((user) => fetchProfile(user.id))
.then((profile) => fetchPosts(profile.userId))
.then((posts) => renderPosts(posts))
.catch((error) => {
// Catches error from ANY step above
console.error("Failed:", error.message);
showErrorPage();
});
// Pattern 2: .catch() mid-chain for recovery
fetchUser()
.then((user) => fetchProfile(user.id))
.catch((error) => {
// Handle profile fetch failure — return a default
console.warn("Profile fetch failed, using default");
return { name: "Anonymous", avatar: "default.png" };
})
.then((profile) => {
// This runs with either the real profile or the default
renderProfile(profile);
});
A mid-chain .catch() is how you recover from a specific failure and keep the rest of the chain running — a trick you can't cleanly do with a single top-level try/catch.
try/catch with async/await
// Approach 1: Wrapping the whole block
async function loadPage() {
try {
const user = await fetchUser();
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
renderPage({ user, posts, comments });
} catch (error) {
console.error("Page load failed:", error);
renderErrorPage();
} finally {
hideLoadingSpinner();
}
}
// Approach 2: Per-operation error handling (when you need different handling)
async function loadPageGranular() {
let user;
try {
user = await fetchUser();
} catch (error) {
console.error("User fetch failed");
return renderLoginPage(); // can't continue without user
}
let posts;
try {
posts = await fetchPosts(user.id);
} catch (error) {
console.warn("Posts unavailable");
posts = []; // graceful degradation
}
renderPage({ user, posts });
}
A Helper Pattern to Avoid try/catch Boilerplate
// Utility function — wraps any promise into a [error, result] tuple
async function to(promise) {
try {
const result = await promise;
return [null, result];
} catch (error) {
return [error, null];
}
}
// Usage — clean, Go-style error handling
async function loadUser() {
const [err, user] = await to(fetchUser(1));
if (err) {
console.error("Failed:", err.message);
return;
}
const [err2, posts] = await to(fetchPosts(user.id));
if (err2) {
console.warn("Posts failed, continuing without them");
}
renderDashboard({ user, posts: posts || [] });
}
Global Unhandled Rejection Handlers
// Browser
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled promise rejection:", event.reason);
event.preventDefault(); // prevents default console error
// Send to error tracking service (Sentry, etc.)
});
// Node.js
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
// Log it, but don't crash — or choose to crash gracefully
});
These are safety nets, not primary handlers. Every Promise should still have its own local handling — the global hook is where you catch what slipped through.
Common Pitfall: Floating Promises
// BAD — "fire and forget" without error handling
async function handleClick() {
saveData(); // returns a promise, but nobody awaits or catches it!
}
// GOOD — always handle the Promise
async function handleClick() {
try {
await saveData();
} catch (err) {
showError(err);
}
}
// Also GOOD — if you intentionally fire-and-forget
function handleClick() {
saveData().catch((err) => showError(err));
}
Common Mistakes
- Leaving Promises "floating" — calling an async function without
awaitor.catch()means its rejection becomes invisible until it crashes the process or surfaces in a global handler far from the scene of the bug. - Putting a single top-level
try/catcharound a block where you actually needed per-step recovery — once anyawaitthrows, every later step is skipped, even ones that could have degraded gracefully. - Relying on the global
unhandledrejectionhandler as your primary error strategy — it exists as a net for bugs, not as a substitute for local error handling.
Interview Questions
Q: What happens if a Promise rejects and there's no .catch() or try/catch?
It becomes an unhandled rejection. In browsers, it logs a warning and fires an
unhandledrejectionevent. In Node.js (v15+), it terminates the process by default. Always handle Promise rejections.
Q: How do you handle errors with async/await?
Wrap
awaitcalls intry/catchblocks. Thecatchblock receives the rejection reason. Usefinallyfor cleanup. For per-operation handling, use multipletry/catchblocks or a utility wrapper that returns[error, result]tuples.
Q: What is a "floating Promise" and why is it dangerous?
A floating Promise is one that's neither awaited nor has a
.catch()handler attached. If it rejects, the error is silently swallowed (or triggers an unhandled rejection). Alwaysawaitor.catch()your Promises.
Q: How can you catch unhandled rejections globally?
In browsers:
window.addEventListener("unhandledrejection", handler). In Node.js:process.on("unhandledRejection", handler). These are safety nets — always prefer explicit error handling.
Q: What happens if you forget try/catch around await?
The rejection escapes the function, bubbles up as a rejected Promise to the caller, and if nobody catches it anywhere up the chain, it becomes an unhandled rejection — logged in browsers, process-terminating in Node.js 15+.
Quick Reference — Cheat Sheet
ASYNC ERROR HANDLING — QUICK MAP
Primary patterns:
async/await -> try { await fn() } catch (e) {...} finally {...}
promise -> fn().then(...).catch(e => ...).finally(...)
mid-chain -> ...then(...).catch(recover).then(continue)
go-style -> const [err, val] = await to(fn())
Global safety nets (last resort):
Browser: window.addEventListener("unhandledrejection", h)
Node.js: process.on("unhandledRejection", h)
Rules:
+ try/catch every await that can reject
+ .catch() every chain you don't await
+ mid-chain .catch() to recover + continue
- Never leave Promises floating (no await, no .catch)
Node 15+ default:
Unhandled rejection -> process exits.
Previous: Promise Combinators — all, race, allSettled, any Next: Implicit Binding — How
thisFinds Its Object
This is Lesson 3.10 of the JavaScript Interview Prep Course — 14 chapters, 87 lessons.