Error Handling
try/catch/finally, custom Error classes, and handling errors in async/await and Promise chains.
try/catch/finally
JavaScript signals a runtime problem by throwing — anything thrown propagates up through function calls until something catches it or, uncaught, it crashes the program (in Node) or logs to the console and halts that script's execution (in the browser).
function divide(a, b) {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}
try {
console.log(divide(10, 0));
} catch (error) {
console.error("Something went wrong:", error.message);
} finally {
console.log("Division attempt finished");
}
catch receives whatever was thrown — by convention (and it should always be) an Error object, though JavaScript technically permits throwing anything (throw "oops", throw 42). finally runs unconditionally, whether the try block succeeded, threw, or even returned early — the standard place for cleanup that must always happen:
function readConfig(path) {
let handle;
try {
handle = openFile(path);
return parseConfig(handle.read());
} catch (error) {
console.error(`Failed to read config: ${error.message}`);
return {};
} finally {
handle?.close(); // runs whether readConfig succeeded, failed, or returned early above
}
}
Always throw Error objects
Error (and its built-in subclasses TypeError, RangeError, SyntaxError) automatically captures a stack trace — exactly where the error was created — which is essential for debugging. Throwing a plain string or number discards that information entirely:
// Bad — no stack trace, no consistent shape to check against
throw "Invalid input";
// Good — carries a stack trace and a consistent .message/.name shape
throw new Error("Invalid input");
try {
JSON.parse("{ not valid json");
} catch (error) {
console.log(error instanceof SyntaxError); // true
console.log(error.message); // "Unexpected token o in JSON at position 2" (message varies by engine)
}
Custom error classes
Extending the built-in Error class lets application code represent specific failure conditions as their own recognizable type — exactly the same idea as a custom exception class in Python or Java:
class InsufficientFundsError extends Error {
constructor(balance, amount) {
super(`Cannot withdraw ${amount}: balance is only ${balance}`);
this.name = "InsufficientFundsError"; // shows up in stack traces and error.name
this.balance = balance;
this.amount = amount;
}
}
class BankAccount {
#balance;
constructor(balance = 0) {
this.#balance = balance;
}
withdraw(amount) {
if (amount > this.#balance) {
throw new InsufficientFundsError(this.#balance, amount);
}
this.#balance -= amount;
}
}
const account = new BankAccount(100);
try {
account.withdraw(250);
} catch (error) {
if (error instanceof InsufficientFundsError) {
console.log(error.message); // Cannot withdraw 250: balance is only 100
console.log(error.balance, error.amount); // 100 250 — structured data, not just text
} else {
throw error; // some other, unexpected error — don't swallow it silently
}
}
super(message) must be called before accessing this inside the subclass constructor — it runs the base Error constructor, which is what actually sets up .message and captures the stack trace. Attaching extra fields (balance, amount) directly onto the error, rather than only encoding them into the message string, lets calling code make decisions based on the failure without parsing text back out of it.
A base error class for a whole application lets calling code catch broadly or narrowly, its choice:
class AppError extends Error {
constructor(message) {
super(message);
this.name = this.constructor.name;
}
}
class InsufficientFundsError extends AppError {}
class AccountFrozenError extends AppError {}
try {
account.withdraw(250);
} catch (error) {
if (error instanceof AppError) {
console.log(`Application-level failure: ${error.message}`);
} else {
throw error; // a genuine bug or unexpected error, not a known application failure
}
}
Error handling in async code
With async/await
await-ed code that throws (or a Promise that rejects) is caught with an ordinary try/catch, exactly like synchronous code — this is one of async/await's biggest ergonomic wins over raw Promise chains:
async function loadUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Failed to load user:", error.message);
throw error; // re-throw so the caller also knows this failed
}
}
A try/catch around await only catches errors from the code inside that block — a common mistake is awaiting inside try but leaving a related call outside it unprotected:
async function processOrder(orderId) {
const order = await fetchOrder(orderId); // not wrapped — an error here is NOT caught below
try {
await chargeCard(order.total);
} catch (error) {
console.error("Payment failed:", error.message);
}
}
With Promise chains
.catch() on a Promise chain catches a rejection from any earlier .then() in the chain, not just the immediately preceding one — a single .catch() at the end is usually enough for a whole chain:
fetchUser(1)
.then(user => fetchOrders(user.id))
.then(orders => processOrders(orders))
.catch(error => {
// catches a rejection from fetchUser, fetchOrders, OR processOrders — whichever failed
console.error("Pipeline failed:", error.message);
});
Unhandled rejections
A rejected Promise with no .catch() anywhere in its chain (and no surrounding try/catch if it was await-ed) doesn't throw synchronously — it fires an unhandledrejection event. In the browser this logs a console warning without crashing the page; in modern Node.js it terminates the process by default, since an unhandled rejection is treated as seriously as an uncaught synchronous exception:
async function riskyOperation() {
throw new Error("Something broke");
}
riskyOperation(); // no await, no .catch() — an unhandled rejection
// Fix: either await it inside a try/catch, or attach .catch() directly
riskyOperation().catch(error => console.error(error.message));
Comparing the two styles
try/catch with await |
.then()/.catch() chain |
|
|---|---|---|
| Reads like | Synchronous code | A pipeline of transformations |
| Catches errors from | Everything inside the try block |
Any earlier link in the chain |
| Common pitfall | Code outside the try block isn't protected |
Forgetting the trailing .catch() entirely |
| Preferred for | Most modern code — sequential steps, clearer control flow | Simple one-step chains, or when composing several independent Promises |
Common mistakes
- Throwing a plain string or object literal instead of an
Error(or subclass) — it loses the automatic stack trace, andcatchblocks that assumeerror.messageexists will break on it. - Leaving off
.catch()on a Promise chain (or a surroundingtry/catchon anawait) — an unhandled rejection is a silent failure in the browser and can crash a Node process outright. - Wrapping only part of a sequence of
awaitcalls intry/catch, leaving an earlier or laterawaitin the same function unprotected and assuming thecatchcovers the whole function. - Catching an error and silently discarding it (
catch (error) {}) instead of at least logging it — the failure vanishes with no trace, making it far harder to diagnose later.
Interview questions
Q: Why should you always throw an Error object instead of a plain string or number?
Error (and its subclasses) automatically capture a stack trace at the point it's created, showing exactly where and how the failure originated — essential for debugging. Throwing a plain value carries no such trace and no consistent shape (.message, .name) that catching code can rely on, so error-handling code has to guess at what it received instead of consistently reading .message.
Q: What's the difference between wrapping an await in try/catch versus attaching .catch() to a Promise chain?
A try/catch around awaited code catches a rejection from anything inside that specific try block, and reads like ordinary synchronous error handling — but code outside the block isn't protected by it. A .catch() at the end of a .then() chain catches a rejection from any earlier step in that chain, not just the immediately preceding .then(), which is why one .catch() is often enough for a whole multi-step chain. Both ultimately handle the same underlying mechanism — a rejected Promise — just with different syntax and different scoping of what's actually covered.