Error Handling
Why your code breaks, how to catch it, and the art of failing gracefully.
Every developer learns this eventually, usually at the worst possible time. You write something, it works on your machine, you ship it, and then a user does something you didn't predict, types letters into a number field, loses their internet connection mid-request, passes in an empty string where you expected an array. The code falls over.
The mistake isn't that the code broke. That's going to happen. The mistake is not planning for it. A user who hits an unhandled error sees nothing useful. You, the developer, also see nothing useful, no message, no context, no idea what went wrong. Compare that to code that catches the error, logs it properly, and shows the user something sensible. Same breakage, completely different experience for everyone involved.
Before getting into how to handle errors, it's worth knowing the types you'll actually run into. There are three that show up constantly.
ReferenceError: You used a variable that doesn't exist. Usually a typo or a scoping problem.
TypeError: You tried to call something that isn't a function, or access a property on null or undefined.
SyntaxError: The code itself is malformed. A missing bracket, a bad JSON string. Usually caught before the script even runs.
RangeError: A value is outside an acceptable range.
NetworkError: Not a built-in JS type, but the most common runtime failure. The fetch failed. The API timed out. The user went offline.
Try and Catch
The fix for unhandled errors is a try...catch block. Put the code that might fail inside try. If it throws, JavaScript jumps straight to catch and hands you the error object. The rest of try doesn't run and nothing crashes.
try {
// This will throw a ReferenceError
const result = undeclaredVariable * 2;
console.log(result); // never runs
} catch (err) {
// err is the Error object JavaScript created
console.log(err.name); // "ReferenceError"
console.log(err.message); // "undeclaredVariable is not defined"
}
// Code runs normally
console.log("we're still going");
The error object always has two properties you'll use: name tells you the type of error, and message tells you what went wrong. In modern environments there's also stack, which gives you the full call trace. That one is invaluable when you're debugging something three functions deep.
One thing that trips people up: try...catch only works on runtime errors. If your code has a syntax error, JavaScript never runs the block at all. And if you're working with async code, a plain try...catch won't catch promise rejections unless you're inside an async function. There's a separate section on that below.
The Finally Block
There's a third part to the pattern that a lot of people skip until they actually need it. finally runs no matter what. Whether the try succeeded, whether catch ran, whether someone threw again inside catch. It doesn't matter. finally runs.
The use case is cleanup. If you opened a database connection, you need to close it. If you set a loading spinner to visible, you need to hide it. These things should happen regardless of whether the operation worked. That's exactly what finally is for.
async function loadUserData(id) {
showSpinner(); // loading starts
try {
const res = await fetch(`/api/users/${id}`);
const data = await res.json();
renderProfile(data);
} catch (err) {
showErrorMessage("Couldn't load profile. Try again.");
console.error(err);
} finally {
hideSpinner(); // always runs
}
}
Without finally, you'd have to call hideSpinner() in both the success path and the catch block. That's easy to forget, especially when the function grows. finally means you write that cleanup logic once and it always runs.
A good mental model: try is the attempt, catch is the fallback, finally is the cleanup.
Throwing Custom Errors
JavaScript lets you throw errors yourself with the throw keyword. You can technically throw anything, a string, a number, an object. In practice, always throw an Error object. That's because Error objects include a stack trace automatically, which is the thing you actually want when debugging.
function divide(a, b) {
if (b === 0) {
throw new Error("Division by zero is not allowed");
}
return a / b;
}
try {
divide(10, 0);
} catch (err) {
console.log(err.message); // "Division by zero is not allowed"
}
For bigger applications, you'll want to extend Error with custom classes. This way a catch block can check what kind of error it got and respond differently, show a 404 UI for not-found errors, show a login prompt for auth errors, and so on.
class NotFoundError extends Error {
constructor(resource) {
super(${resource} not found);
this.name = "NotFoundError";
this.resource = resource;
}
}
class AuthError extends Error {
constructor(msg) {
super(msg);
this.name = "AuthError";
}
}
async function getPost(id) {
const res = await fetch(/api/posts/${id});
if (res.status === 401) throw new AuthError("Session expired");
if (res.status === 404) throw new NotFoundError("post");
return res.json();
}
try {
await getPost(99);
} catch (err) {
if (err instanceof AuthError) redirectToLogin();
if (err instanceof NotFoundError) show404Page();
else showGenericError(err.message);
}
The instanceof check is what makes this pattern useful. One catch block, multiple kinds of failure, each handled differently. And if something unexpected comes through, the else branch catches it so nothing goes silently missing.
Why This Matters




