Why errors are inevitable
Every program eventually meets a situation it did not expect: a server returns a 500, a user types letters into a numeric field, a file is missing, a third-party API changes shape. Error handling is not about preventing every failure — it is about deciding what happens when one occurs.
Well-handled errors keep an application running, tell the user something useful and give developers the detail they need. Poorly handled errors produce blank screens, silent data loss and bug reports that are impossible to reproduce.
The Error object
JavaScript has a built-in Error type, and every thrown error is usually an instance of it.
// error.js
const error = new Error("Something broke");
error.message; // "Something broke"
error.name; // "Error"
error.stack; // the call stack at the point of creation
error.cause; // an optional underlying error
There are several built-in subclasses for common categories: TypeError, ReferenceError, RangeError, SyntaxError, URIError and EvalError. They mostly differ in name and the situations that produce them, which makes them useful for debugging and for branching on the kind of failure.
Throwing errors
Use throw to signal that a rule has been violated. Throw an Error object, never a string or a number — only Error instances carry a message, a name and a stack trace.
// validate.js
function divide(a, b) {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}
Throwing early, at the exact point where an assumption breaks, keeps invalid state from spreading deeper into your program. Failing loudly in development is a feature, not a nuisance.
try, catch and finally
try runs risky code. If anything throws, control jumps to catch. finally runs either way, which makes it the place for cleanup.
// try.js
try {
const data = JSON.parse(input);
save(data);
} catch (error) {
console.error("Invalid data:", error.message);
} finally {
setLoading(false);
}
The catch parameter is the thrown value. Modern JavaScript also supports optional catch binding when you do not need it: catch { ... }. Inside a catch block you can inspect the error, log it, recover, or re-throw it if you cannot handle it.
// rethrow.js
try {
await loadProfile();
} catch (error) {
log(error);
throw error; // let a higher layer decide
}
Custom error classes
Generic errors lose meaning once your app grows. Custom classes let you attach context and branch on the type of failure.
// http-error.js
class HttpError extends Error {
constructor(status, message) {
super(message ?? `HTTP ${status}`);
this.name = "HttpError";
this.status = status;
}
}
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
Now callers can react precisely:
// handle.js
try {
await save(form);
} catch (error) {
if (error instanceof ValidationError) {
showFieldError(error.field, error.message);
} else if (error instanceof HttpError && error.status === 401) {
redirectToLogin();
} else {
showGenericError();
}
}
This is the difference between “something went wrong” and “the email field is already taken”.
Wrapping errors with cause
Sometimes you want to add context without losing the original failure. The cause option preserves the chain.
// cause.js
try {
await db.query(sql);
} catch (error) {
throw new Error("Failed to load orders", { cause: error });
}
The outer error carries the user-friendly context, while error.cause keeps the original detail for logs and debugging.
Errors in asynchronous code
Async code has two extra failure paths. With async/await, try/catch works exactly as it does for synchronous code.
// async.js
async function load() {
try {
const res = await fetch("/api/data");
if (!res.ok) throw new HttpError(res.status);
return await res.json();
} catch (error) {
console.error("Load failed:", error);
throw error;
}
}
Without await, attach a handler to the promise:
// promise.js
fetch("/api/data")
.then((res) => res.json())
.catch((error) => console.error(error));
A rejected promise with no handler becomes an unhandled rejection. In browsers it logs a warning; in Node.js it can terminate the process. Always handle rejections, even if only to log and re-throw.
Global safety nets
Even with careful code, something will slip through. Global handlers catch what you missed so the app can report it instead of dying silently.
// global.js
window.addEventListener("error", (event) => {
reportToServer(event.error);
});
window.addEventListener("unhandledrejection", (event) => {
reportToServer(event.reason);
});
In Node.js the equivalents are process.on("uncaughtException") and process.on("unhandledRejection"). Treat these as last resorts for logging and graceful shutdown, not as a substitute for handling errors where they occur.
Validate before you throw
Not every problem is exceptional. Expected conditions — empty input, a missing optional field, a search with no results — are better handled with validation than exceptions.
// guards.js
function formatName(user) {
if (!user?.name) return "Anonymous";
return user.name.trim();
}
Reserve exceptions for genuinely unexpected or unrecoverable situations. Using them for ordinary control flow makes code slower and harder to follow.
Talking to users
A technical error message is for developers. Users need to know what happened and what they can do next.
- Keep messages short and human: “We could not save your changes. Check your connection and try again.”
- Avoid jargon, stack traces and raw status codes in the interface.
- Provide a next step: retry, go back, contact support or continue offline.
- Log the full detail — message, stack, context — so support can investigate.
Best practices
- Throw
Errorobjects, never strings. - Catch only where you can recover or add useful context.
- Use custom error classes for domain-specific failures.
- Wrap errors with
causeinstead of discarding the original. - Always handle promise rejections.
- Add global handlers as a final safety net, and log to a real service.
- Validate expected conditions instead of throwing.
- Keep user messages separate from developer diagnostics.
- Write tests for your failure paths, not just the happy path.
Common mistakes
- Swallowing errors with an empty
catchblock. - Throwing strings and losing the stack trace.
- Catching an error and returning a default that hides a real bug.
- Forgetting to handle rejected promises.
- Showing raw error messages to users.
- Using exceptions for ordinary control flow.
- Assuming
try/catcharound a promise executor catches asynchronous errors — it does not.
Where to go next
Errors are where async code and the Fetch API meet reality. Pair the patterns here with the DOM to render friendly states, and revisit JavaScript fundamentals whenever a TypeError reminds you that undefined is not a function.