JavaScript try/catch Tutorial: Advanced Error Handling
Go beyond basic try/catch in JavaScript. Learn advanced error handling patterns including typed error checking, rethrowing, finally semantics, error boundaries, global handlers, and building a robust error handling strategy.
Most developers know the basic try/catch block. Advanced error handling goes much further: distinguishing error types, rethrowing appropriately, using finally correctly, building error hierarchies with custom classes, and maintaining global safety nets. This guide covers the patterns that separate fragile code from robust, debuggable applications.
The try/catch/finally Structure
try {
// Code that might throw
const data = JSON.parse(rawInput);
processData(data);
} catch (error) {
// Runs if try block throws
console.error("Parse or process error:", error.message);
} finally {
// ALWAYS runs — after try OR catch
cleanup();
}All three blocks are optional in combination, but try must be paired with at least one of catch or finally.
The finally Block Semantics
finally always executes, even if:
- The try block returns a value
- The catch block returns a value
- The catch block throws
function example() {
try {
return "from try"; // finally still runs!
} finally {
console.log("finally runs"); // Logs before returning
// NOT returning here — the "from try" value is preserved
}
}
console.log(example()); // "finally runs", then "from try"
// If finally DOES return, it overrides the try/catch return:
function overrideExample() {
try {
return "from try";
} finally {
return "from finally"; // Overrides!
}
}
console.log(overrideExample()); // "from finally"Best practice: Never return or throw from finally — it silently swallows errors or overrides return values. Use finally only for side effects (cleanup, logging, spinner hiding).
Typed Error Handling
Catch-all catch blocks treat every error the same. Typed error handling lets you respond differently to different failures:
// Using instanceof to check error type
try {
const user = JSON.parse(input);
await saveUser(user);
} catch (error) {
if (error instanceof SyntaxError) {
// Input was malformed JSON
showValidationMessage("Invalid JSON format");
} else if (error instanceof NetworkError) {
// Save failed due to network
queueForRetry(user);
} else if (error instanceof AuthError) {
// Missing permissions
redirectToLogin();
} else {
// Unknown error — log and rethrow
logError(error);
throw error;
}
}This pattern — handle known types, rethrow unknowns — is fundamental to writing maintainable error handling.
Rethrowing: The Critical Pattern
A common mistake is swallowing errors you cannot handle:
// BAD: swallowing unknown errors
try {
riskyOperation();
} catch (error) {
console.log("Something went wrong");
// Error is silently consumed — caller has no idea it failed
}
// GOOD: only handle what you understand, rethrow the rest
try {
riskyOperation();
} catch (error) {
if (error instanceof KnownError) {
handleGracefully(error);
} else {
throw error; // Rethrow — let a higher layer handle it
}
}Rethrowing preserves the original stack trace. Re-wrapping is sometimes appropriate when adding context:
try {
await db.query(sql, params);
} catch (error) {
// Add context and rethrow
throw new DatabaseError(`Query failed: ${sql}`, { cause: error });
}
// The caller sees a meaningful DatabaseError with the original cause attachedThe cause option in the Error constructor (ES2022) is the standard way to chain errors while preserving the original:
const dbError = new Error("Query failed", { cause: originalError });
console.log(dbError.cause); // The original database error
console.log(dbError.cause.stack); // Original stack traceCustom Error Classes
Custom error classes enable typed error handling with an organized hierarchy:
// Base application error
class AppError extends Error {
constructor(message, options = {}) {
super(message, options);
this.name = this.constructor.name; // "AppError"
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor); // V8 optimization
}
}
}
// Domain-specific errors
class ValidationError extends AppError {
constructor(message, field) {
super(message);
this.field = field;
}
}
class NetworkError extends AppError {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
}
}
class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} with id ${id} not found`);
this.resource = resource;
this.id = id;
}
}
// Usage
try {
validate(input);
await save(data);
} catch (err) {
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message, field: err.field });
}
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message });
}
if (err instanceof NetworkError) {
return res.status(502).json({ error: "Upstream service unavailable" });
}
// Unknown error: 500
logError(err);
return res.status(500).json({ error: "Internal server error" });
}For more on building and extending custom errors, see creating custom errors in JavaScript and extending the Error class.
Error Handling Strategy Table
| Error Type | Action |
|---|---|
| Expected domain errors (validation, not found) | Handle at site, return user-facing response |
| Recoverable transient errors (network timeout) | Retry with backoff, then fail gracefully |
| Unrecoverable errors (auth failures) | Fail fast, inform user, clean up state |
| Programming errors (TypeError, ReferenceError) | Do NOT catch — let them surface for debugging |
| Unknown errors | Log with full context + stack, rethrow |
Global Error Handlers
Individual try/catch blocks handle expected errors. Global handlers are the safety net:
// Browser: uncaught synchronous errors
window.onerror = function (message, source, lineno, colno, error) {
logToServer({
type: "uncaught_error",
message,
source,
line: lineno,
stack: error?.stack,
});
return false; // false = let browser show default error, true = suppress
};
// Browser: unhandled Promise rejections
window.addEventListener("unhandledrejection", (event) => {
logToServer({
type: "unhandled_rejection",
reason: event.reason?.message || String(event.reason),
stack: event.reason?.stack,
});
});
// Node.js equivalents:
process.on("uncaughtException", (error) => {
logger.fatal(error);
process.exit(1); // Always exit after uncaughtException
});
process.on("unhandledRejection", (reason, promise) => {
logger.error("Unhandled rejection:", reason);
// Consider process.exit(1) in production
});Error Serialization for Logging
Standard JSON.stringify(error) produces {} since Error properties are non-enumerable:
// WRONG: Error properties are non-enumerable
console.log(JSON.stringify(new Error("oops"))); // "{}"
// CORRECT: explicitly serialize fields
function serializeError(error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
cause: error.cause ? serializeError(error.cause) : undefined,
// Custom fields from AppError subclasses:
...(error.field && { field: error.field }),
...(error.statusCode && { statusCode: error.statusCode }),
};
}
logToServer(serializeError(error));Try/Catch in async Functions
In async functions, await causes a rejected Promise to throw at the await line, making try/catch work naturally:
async function loadData(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new NetworkError(`HTTP ${response.status}`, response.status);
}
return await response.json();
} catch (err) {
if (err instanceof NetworkError && err.statusCode === 404) {
return null; // Graceful not-found
}
throw err; // Rethrow everything else
}
}For full coverage of async-specific rejection handling, see handling async errors with try/catch.
Rune AI
Key Insights
- Rethrow errors you cannot handle: Never swallow an error silently — if you cannot handle a specific error type, rethrow it so a higher layer can
- Use instanceof for typed error dispatch: Checking error types enables specific responses to specific failures rather than one-size-fits-all error handling
- finally is for cleanup, not control flow: Never return or throw from finally — it silently overrides try/catch behavior and creates confusing bugs
- Error({ cause }) chains errors with context: ES2022
new Error("message", { cause: originalErr })preserves the full error chain for debugging without losing the original stack trace - Global handlers are safety nets, not handlers: Register
onerrorandunhandledrejectionto catch what slips through, but they are not a substitute for proper per-operation error handling
Frequently Asked Questions
Can I access the error variable outside the catch block?
Should I catch Error or catch everything?
Does catch change the error in the catch block affect the rethrow?
When is it appropriate to NOT use try/catch?
Is try/catch performance-intensive?
Conclusion
Advanced error handling in JavaScript is built on four pillars: typed errors (custom classes enabling instanceof checks), disciplined rethrowing (handling what you understand, rethrowing the rest), proper finally usage (cleanup only, no return or throw), and global safety nets (onerror, unhandledrejection). Combined with clear custom error hierarchies, these patterns make errors visible, debuggable, and recoverable.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.