Error Handling in JavaScript: Try, Catch, Finally
Errors are inevitable. Whether your program crashes or recovers gracefully is entirely up to how you handle them.
What Errors Are in JavaScript
A JavaScript error is a signal that something went wrong during execution. The program encountered a situation it couldn't handle, a missing variable, an impossible operation, a malformed response, and it has two choices: crash the entire program, or surface the problem in a controlled way.
Without any error handling, a crash looks like this:
const user = null;
console.log(user.name); // TypeError: Cannot read properties of null
// Everything after this line never runs
console.log("This line is never reached.");
The program stops dead. Any code that came after the error, saving a draft, closing a connection, showing the user a message, is simply abandoned.
JavaScript has three built-in categories of errors you'll encounter regularly:
SyntaxError — Code that JavaScript can't parse. Caught before execution even begins.
const x = {; // SyntaxError: Unexpected token ';'
ReferenceError — Accessing a variable that doesn't exist.
console.log(undeclaredVariable); // ReferenceError: undeclaredVariable is not defined
TypeError — Performing an operation on the wrong type of value.
null.toString(); // TypeError: Cannot read properties of null
undefined(); // TypeError: undefined is not a function
(42).toUpperCase(); // TypeError: toUpperCase is not a function
RangeError — A value outside the allowed range.
new Array(-1); // RangeError: Invalid array length
(1.2345).toFixed(200); // RangeError: toFixed() digits argument must be between 0 and 100
These errors, when unhandled, terminate execution at the point they occur. Everything that followed, never runs. That's the problem error handling exists to solve.
The try / catch Block: Catching What Goes Wrong
The try / catch block is JavaScript's core error handling mechanism. It says: try to run this code, if anything throws an error, catch it here instead of crashing.
try {
// Code that might fail
const user = null;
console.log(user.name); // Would normally crash
} catch (error) {
// Code that runs if something throws
console.log("Something went wrong:", error.message);
}
console.log("Program continues normally.");
Output:
Something went wrong: Cannot read properties of null (reading 'name')
Program continues normally.
The program didn't crash. The error was caught, handled gracefully, and execution continued past the try/catch block as if nothing happened.
The error Object
The value caught in catch is an Error object with several useful properties:
try {
undeclaredVariable;
} catch (error) {
console.log(error.name); // "ReferenceError"
console.log(error.message); // "undeclaredVariable is not defined"
console.log(error.stack); // Full stack trace with file names and line numbers
}
error.stack is particularly valuable for debugging, it shows exactly where the error originated and the chain of function calls that led to it.
How the Flow Changes With catch
Visualizing which lines run, and which don't, clarifies the block's behavior:
try {
console.log("Step 1"); // Runs
throw new Error("oops"); // Error thrown here
console.log("Step 2"); // Never runs — skipped immediately
console.log("Step 3"); // Never runs
} catch (error) {
console.log("Caught:", error.message); // Runs
}
console.log("After block"); // Runs
Output:
Step 1
Caught: oops
After block
When an error is thrown inside try, JavaScript immediately jumps to catch, skipping everything between the throw and the end of the try block.
The finally Block: Code That Always Runs
finally is a third optional block that runs no matter what, whether the try block succeeded, whether an error was caught, or even if the catch block itself throws:
try {
console.log("Trying...");
// Success or failure — finally always runs
} catch (error) {
console.log("Caught:", error.message);
} finally {
console.log("Finally — always runs.");
}
Why finally Exists: Guaranteed Cleanup
The purpose of finally is cleanup , releasing resources, closing connections, hiding loading spinners, restoring state, things that must happen regardless of success or failure.
Consider a database connection:
function queryDatabase(sql) {
const connection = openConnection(); // Resource that must be closed
try {
const result = connection.execute(sql);
return result;
} catch (error) {
console.error("Query failed:", error.message);
return null;
} finally {
connection.close(); // Runs whether query succeeded or failed
console.log("Connection closed.");
}
}
Without finally, you'd have to call connection.close() in both the try block (on success) and the catch block (on failure), duplicated code, and easy to forget one path.
finally With a Return Value
finally has one subtle and important behavior: if both try and finally have return statements, the finally return wins:
function test() {
try {
return "from try";
} finally {
return "from finally"; // This wins
}
}
console.log(test()); // "from finally"
This is rarely intentional. In practice, avoid return statements inside finally, use it for side effects (closing, logging, cleanup) only.
The Three Combinations
// try + catch
try { ... } catch (err) { ... }
// try + finally (errors propagate, but cleanup still runs)
try { ... } finally { ... }
// try + catch + finally (most complete)
try { ... } catch (err) { ... } finally { ... }
try + finally without catch is useful when you want errors to propagate naturally but still need guaranteed cleanup:
function withSpinner(operation) {
showSpinner();
try {
return operation(); // Let errors bubble up naturally
} finally {
hideSpinner(); // Always hide — even if operation throws
}
}
Throwing Custom Errors
You're not limited to errors thrown by the JavaScript engine. You can throw your own using the throw keyword, and you can throw anything, though throwing an Error object (or a subclass) is strongly preferred.
Throwing a Basic Error
function divide(a, b) {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}
try {
const result = divide(10, 0);
} catch (error) {
console.log(error.message); // "Cannot divide by zero"
}
Using Built-In Error Types
JavaScript provides specific error constructors for different situations:
function setAge(age) {
if (typeof age !== "number") {
throw new TypeError(`Age must be a number, got ${typeof age}`);
}
if (age < 0 || age > 150) {
throw new RangeError(`Age must be between 0 and 150, got ${age}`);
}
return age;
}
try {
setAge("twenty");
} catch (error) {
console.log(error.name); // "TypeError"
console.log(error.message); // "Age must be a number, got string"
}
Using the right built-in type means error.name is meaningful, and callers can check what kind of error they received.
Custom Error Classes
For application-level errors, extending the built-in Error class gives you custom error types that are identifiable with instanceof:
class ValidationError extends Error {
constructor(field, message) {
super(message); // Sets error.message
this.name = "ValidationError";
this.field = field; // Custom property
}
}
class NotFoundError extends Error {
constructor(resource, id) {
super(`\({resource} with ID \){id} not found`);
this.name = "NotFoundError";
this.resource = resource;
this.id = id;
}
}
function getUser(id) {
if (typeof id !== "number") {
throw new ValidationError("id", "User ID must be a number");
}
const user = database.find(id);
if (!user) {
throw new NotFoundError("User", id);
}
return user;
}
Now in the calling code, you can handle different error types differently:
try {
const user = getUser("abc");
} catch (error) {
if (error instanceof ValidationError) {
console.log(`Validation failed on field "\({error.field}": \){error.message}`);
} else if (error instanceof NotFoundError) {
console.log(`Resource not found: \({error.resource} #\){error.id}`);
} else {
throw error; // Unknown error — re-throw it upward
}
}
Re-throwing unknown errors is an important pattern: handle what you understand, and let everything else propagate to a higher-level handler that might know what to do with it.
A Real-World Example: Handling API Calls
Putting it all together, try, catch, finally, and custom errors, in a realistic fetch scenario:
class ApiError extends Error {
constructor(status, message) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
async function fetchUserProfile(userId) {
setLoadingState(true);
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new ApiError(
response.status,
`Request failed with status ${response.status}`
);
}
const data = await response.json();
return data;
} catch (error) {
if (error instanceof ApiError) {
if (error.status === 404) {
showMessage("User not found.");
} else if (error.status === 401) {
redirectToLogin();
} else {
showMessage(`Server error: ${error.message}`);
}
} else {
// Network error, JSON parse error, etc.
showMessage("A network error occurred. Please check your connection.");
console.error("Unexpected error:", error);
}
return null;
} finally {
setLoadingState(false); // Always hide the spinner — success or failure
}
}
Each piece has a clear role:
try— attempt the operationcatch— handle each failure mode with appropriate user feedbackfinally— restore UI state no matter what happened
Graceful Failure vs Silent Failure
There's a meaningful distinction between these two approaches to handling errors:
Silent failure — swallowing the error without logging or signaling it:
// Bad — error vanishes, no one knows what went wrong
try {
riskyOperation();
} catch (error) {
// nothing here — error is lost
}
This is one of the most dangerous patterns in JavaScript. The program continues, but in a potentially corrupted state. The error is gone. There's no stack trace, no message, nothing to debug later.
Graceful failure — acknowledging the error, preserving the information, and recovering as cleanly as possible:
// Good — error is logged, user is informed, state is preserved
try {
riskyOperation();
} catch (error) {
console.error("[riskyOperation] failed:", error); // Preserve for debugging
showUserFriendlyMessage("Something went wrong. Please try again.");
rollbackPartialChanges(); // Recover cleanly
}
Graceful failure means:
The user understands something went wrong (in plain language, not a stack trace)
The developer has what they need to reproduce and fix it
The application remains in a valid, operable state
Why Error Handling Matters
1. Preventing Cascading Failures
One unhandled error can bring down an entire feature, or an entire page:
// Without error handling — one bad item breaks the whole list
function renderAllProducts(products) {
products.forEach(product => {
renderProduct(product); // If this throws on item 3, items 4-100 never render
});
}
// With error handling — one bad item is skipped, the rest render
function renderAllProducts(products) {
products.forEach(product => {
try {
renderProduct(product);
} catch (error) {
console.error(`Failed to render product ${product.id}:`, error);
renderErrorPlaceholder(product.id);
}
});
}
2. Preserving Debug Information
Every caught error carries a stack trace. Log it. In production, send it to an error tracking service. An error that's caught and logged is findable and fixable. An error that crashes silently is invisible until a user files a complaint.
catch (error) {
// Development
console.error(error.stack);
// Production — send to error tracking
errorTracker.capture(error, { userId, context });
}
3. User Experience
An unhandled error can freeze a page, blank a component, or produce broken UI with no explanation. A handled error gives the user clear feedback — what went wrong, what they can try, and the confidence that their data is safe.
4. Defensive Programming at Boundaries
Any code that touches the outside world — API calls, file reads, user input, localStorage — can fail for reasons outside your control. Error handling at these system boundaries is not optional; it's the difference between software that works in the real world and software that only works in tests.
Key Takeaways
JavaScript errors —
TypeError,ReferenceError,RangeError, and others — terminate execution at the point they're thrown unless caught.trywraps code that might fail;catchreceives the error and handles it;finallyruns regardless — perfect for cleanup.The
errorobject carriesname,message, andstack— use all three for meaningful logging.throwlets you signal your own errors; throwErrorinstances (or subclasses), not strings or plain objects.Custom error classes extend
Errorand allowinstanceofchecks to handle different failure modes differently.Re-throw errors you don't understand — pass them up to a handler that might.
Silent failure (empty
catchblock) is one of the most dangerous patterns in JavaScript.Graceful failure means: the user is informed, the developer has debug info, and the application stays in a valid state.
Error handling at system boundaries — API calls, user input, external storage — is non-negotiable.
Quick Reference
// Basic try / catch
try {
riskyCode();
} catch (error) {
console.error(error.name, error.message);
}
// try / catch / finally
try {
const data = fetchData();
process(data);
} catch (error) {
handleError(error);
} finally {
cleanup(); // Always runs
}
// Throwing a built-in error
throw new TypeError("Expected a number");
throw new RangeError("Value must be 0–100");
// Custom error class
class AppError extends Error {
constructor(message, code) {
super(message);
this.name = "AppError";
this.code = code;
}
}
// Handle by type
try {
operation();
} catch (error) {
if (error instanceof ValidationError) { /* ... */ }
else if (error instanceof NotFoundError) { /* ... */ }
else throw error; // Re-throw unknown errors
}
// try + finally without catch (errors propagate, cleanup still runs)
try {
return operation();
} finally {
cleanup();
}
An application that crashes loudly and informatively is far better than one that fails silently. Error handling is not defensive pessimism, it's engineering professionalism.

