Skip to main content

Command Palette

Search for a command to run...

Error Handling in JavaScript: Try, Catch, Finally

Published
5 min read
Error Handling in JavaScript: Try, Catch, Finally
S
Full-stack developer obsessed with performance, scalability, and clean systems. I use Arch btw.

Introduction

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.


What Errors Actually Are

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 and catch Blocks

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.


The finally Block

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.


Throwing Custom Errors

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.


Why Error Handling Matters

There are three practical reasons to get this right.

Your users get a better experience

A caught error can show a friendly message or a fallback UI. An uncaught one shows nothing, or breaks the page mid-render.

Debugging gets easier

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.

Your app keeps running

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.

Handling Async Err

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);
}

Conclusion

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.