Error Handling in JavaScript: Try, Catch, Finally

Search for a command to run...

No comments yet. Be the first to comment.
Introduction If you've ever built something with vanilla JavaScript and had to update the UI frequently, you already know the pain. You call document.getElementById, change some text, update a class,
Introduction If you've ever installed and tried out Linux after seeing a cool hacking sequence in a sci-fi movie, you know that opening it feels like stepping into a strange city with many roads named

Introduction Most JavaScript confusion around this comes from one wrong assumption: that this depends on where a function is defined. It doesn't. It depends on who called the function. That one shift

Introduction JavaScript gives you objects and arrays out of the box, and for a long time those two were the default answer to almost every data storage problem. Objects for key-value pairs, arrays for

Introduction Most JavaScript codebases are full of code like this: const user = { name: "Sara", age: 28, city: "Mumbai" }; const name = user.name; const age = user.age; const city = user.city; Three

You write some code. It looks right. You run it, and instead of the output you expected, the browser throws a red wall of text and the whole script stops dead. That's a runtime error, and if you haven't planned for it, your user just saw a broken page.
JavaScript has a structured way to handle this: the try...catch...finally block. It's not glamorous, but understanding it properly will save you a lot of late-night debugging.
JavaScript has several built-in error types. You'll run into these most often:
ReferenceError: accessing a variable that doesn't exist
TypeError: calling something that isn't a function, or reading a property off null
SyntaxError: malformed code (usually caught before runtime)
RangeError: a value that falls outside an allowed range
Here's a classic example:
const user = null;
console.log(user.name); // TypeError: Cannot read properties of null
JavaScript tries to access .name on null, fails, and throws an error. Without any handling, execution stops right there. Everything below that line never runs.
The try block wraps code that might fail. If it does, execution jumps to catch, which receives the error as an argument.
try {
const user = null;
console.log(user.name);
} catch (error) {
console.log("Something went wrong:", error.message);
}
The script no longer crashes. Instead of a red wall of text, you get a controlled message. That's what "graceful failure" means in practice. Not pretending errors don't happen, just deciding what to do when they do.
The error object has a few useful properties:
error.message: a human-readable description
error.name: the type of error ("TypeError", "ReferenceError", etc.)
error.stack: the full call stack, useful for debugging
Getting into the habit of logging error.stack (not just error.message) during development will show you exactly where things went wrong, including the file and line number.
finally runs no matter what, whether the try block succeeded, threw an error, or even hit a return statement.
function fetchData() {
try {
// simulate a risky operation
const data = JSON.parse("invalid json {{{");
return data;
} catch (error) {
console.log("Failed to parse:", error.message);
} finally {
console.log("Cleanup done.");
}
}
The finally block is where you put cleanup code: closing a database connection, hiding a loading spinner, releasing a lock. Stuff that needs to happen regardless of whether the operation worked.
A common real-world pattern looks like this:
async function loadUser(id) {
showSpinner(true);
try {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
renderUser(user);
} catch (error) {
showErrorMessage("Could not load user.");
} finally {
showSpinner(false);
}
}
The spinner always gets hidden. It doesn't matter if the fetch succeeded or failed. Without finally, you'd need to call showSpinner(false) in both the try and the catch, and you'd inevitably miss a case.
JavaScript lets you throw your own errors with the throw keyword. You can throw a string, a number, or an object, but the cleanest approach is to throw an actual Error instance so you get a stack trace.
function divide(a, b) {
if (b === 0) {
throw new Error("Division by zero is not allowed.");
}
return a / b;
}
try {
const result = divide(10, 0);
} catch (error) {
console.log(error.message); // "Division by zero is not allowed."
}
You can also extend the built-in Error class to create named custom errors. This is useful when you want to handle different error types differently:
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
function validateAge(age) {
if (typeof age !== "number") {
throw new ValidationError("Age must be a number.");
}
if (age < 0 || age > 120) {
throw new ValidationError("Age must be between 0 and 120.");
}
}
try {
validateAge("twenty");
} catch (error) {
if (error instanceof ValidationError) {
console.log("Validation failed:", error.message);
} else {
throw error; // re-throw unexpected errors
}
}
Notice the re-throw at the end. If the error isn't a ValidationError, you probably don't know how to handle it, so let it bubble up. Swallowing all errors silently is one of the more frustrating bugs to track down, because nothing tells you something went wrong.
There are three practical reasons to get this right.
A caught error can show a friendly message or a fallback UI. An uncaught one shows nothing, or breaks the page mid-render.
When you log errors consistently, you build a trail. Adding something like console.error(error.stack) in your catch blocks gives you a line number and a call path, not just a vague failure.
An unhandled error in one place can cascade and break unrelated parts of the page. Containing it means the rest of your code still works.
One thing worth knowing: try...catch only catches synchronous errors by default. For async code, you need to use it inside an async function with await, or chain .catch() on a promise. Forgetting this is a common source of "my error handler isn't catching anything" confusion.
// This does NOT catch the rejection:
try {
fetch("/api/data"); // not awaited
} catch (error) {
console.log("This never runs");
}
// This DOES:
try {
const response = await fetch("/api/data");
} catch (error) {
console.log("Network error:", error.message);
}
Error handling isn't about anticipating every possible failure. It's about deciding, ahead of time, what your code should do when something goes wrong. The try block says "try this." The catch block says "if it breaks, do this instead." The finally block says "either way, do this."
That structure alone handles most of the cases you'll encounter. Custom errors and re-throwing handle the rest.