When I first started writing JavaScript professionally, I treated errors like an afterthought. I’d write my happy-path code, ship it, and only think about error handling after something broke in production and I got a panicked message from a client. Over the years I’ve completely flipped that mindset. Now I think about what can go wrong before I write the code that does the thing. In this article, I want to walk you through everything I’ve learned about handling errors in JavaScript — from the absolute basics of try/catch to the internal mechanics of how the engine propagates errors, all the way to production-grade error handling strategies I use in real apps today.
Why Error Handling Matters
Every application, no matter how well written, will encounter unexpected situations: a network request fails, a user types text into a field that expects a number, an API returns malformed JSON, or a third-party script throws because it wasn’t loaded yet. If I don’t handle these cases, my application crashes, users see a blank white screen, and I lose their trust. Good error handling is what separates a hobby project from production-grade software.
The Basics: try, catch, finally
JavaScript gives me a built-in mechanism for handling runtime errors: the try...catch...finally statement.
try {
const result = riskyOperation();
console.log(result);
} catch (error) {
console.error("Something went wrong:", error.message);
} finally {
console.log("This always runs, error or not.");
}
Here’s how I think about each block:
- try: I put code that might throw in here.
- catch: If an error is thrown anywhere in the
tryblock, execution jumps here immediately. Theerrorobject contains details about what happened. - finally: This runs no matter what — whether the try block succeeded, failed, or even if I
returnfrom inside try/catch. I use this for cleanup, like closing a database connection or hiding a loading spinner.
Output Example
try {
null.foo();
} catch (error) {
console.error(error.message);
}
// Output: Cannot read properties of null (reading 'foo')
The Error Object and Its Anatomy
When JavaScript throws an error, it creates an Error object (or a subclass of it). I always inspect these three properties:
| Property | Description |
|---|---|
name | The type of error, e.g. TypeError, RangeError, SyntaxError |
message | A human-readable description of what went wrong |
stack | A stack trace showing where the error occurred (non-standard but supported everywhere) |
try {
JSON.parse("{ invalid json }");
} catch (error) {
console.log(error.name); // SyntaxError
console.log(error.message); // Unexpected token i in JSON at position 2
console.log(error.stack); // Full stack trace
}
Built-in Error Types
JavaScript ships with several built-in error constructors, and I’ve learned to recognize each one because it tells me instantly what category of bug I’m dealing with:
| Error Type | When It’s Thrown |
|---|---|
Error | Generic base error, or when I create custom errors |
TypeError | When a value isn’t of the expected type (e.g., calling a non-function) |
RangeError | When a number is outside an allowed range (e.g., invalid array length) |
ReferenceError | When referencing a variable that doesn’t exist |
SyntaxError | When code can’t be parsed (often from eval() or JSON.parse()) |
URIError | When global URI functions like decodeURIComponent() are misused |
EvalError | Legacy, rarely thrown in modern engines |
Throwing My Own Errors
I don’t have to wait for the engine to throw errors — I can throw my own using the throw keyword. Technically, I can throw any value in JavaScript (a string, a number, an object), but I always throw actual Error instances because they carry a stack trace, which is invaluable for debugging.
function withdraw(balance, amount) {
if (amount > balance) {
throw new Error("Insufficient funds");
}
return balance - amount;
}
try {
withdraw(100, 150);
} catch (error) {
console.error(error.message); // Insufficient funds
}
Custom Error Classes
In real-world applications, I almost always create custom error classes by extending Error. This lets me distinguish between different failure modes programmatically instead of parsing error message strings (which is fragile and something I actively avoid).
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
class NetworkError extends Error {
constructor(message, statusCode) {
super(message);
this.name = "NetworkError";
this.statusCode = statusCode;
}
}
function validateAge(age) {
if (age < 0) {
throw new ValidationError("Age cannot be negative", "age");
}
}
try {
validateAge(-5);
} catch (error) {
if (error instanceof ValidationError) {
console.log(`Validation failed on field: ${error.field}`);
} else {
throw error; // rethrow anything I don't know how to handle
}
}
I want to highlight that last else branch — rethrowing unknown errors instead of silently swallowing them. This is one of the most important habits I’ve built. Catching every error and doing nothing with it hides bugs and makes debugging a nightmare later.
Handling Errors in Asynchronous Code
Error handling gets more nuanced once asynchronous code enters the picture, because a regular try/catch around a callback-based async call won’t catch errors that happen later, in a different turn of the event loop.
Callbacks
function fetchData(callback) {
setTimeout(() => {
try {
throw new Error("Failed to fetch");
} catch (error) {
callback(error, null);
}
}, 1000);
}
fetchData((error, data) => {
if (error) {
console.error("Error in callback:", error.message);
return;
}
console.log(data);
});
Notice I can’t wrap fetchData(callback) itself in a try/catch and expect it to catch errors thrown inside the setTimeout — by the time that code runs, the surrounding try/catch has already finished executing. This trips up a lot of beginners.
Promises
With Promises, I use .catch() to handle rejections:
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error("Network timeout"));
}, 1000);
});
}
fetchData()
.then((data) => console.log(data))
.catch((error) => console.error("Caught:", error.message));
Async/Await
This is the pattern I use most in modern code because try/catch works exactly the way I intuitively expect:
async function loadUser() {
try {
const response = await fetch("https://api.example.com/user");
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const user = await response.json();
return user;
} catch (error) {
console.error("Failed to load user:", error.message);
throw error; // let the caller decide what to do next
}
}
Global Error Handling
I always add a safety net at the application level to catch anything that slips through my local try/catch blocks.
In the Browser
window.addEventListener("error", (event) => {
console.error("Uncaught error:", event.message, event.filename, event.lineno);
});
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled promise rejection:", event.reason);
});
In Node.js
process.on("uncaughtException", (error) => {
console.error("Uncaught exception:", error);
process.exit(1); // I always exit after an uncaught exception — the app is in an unknown state
});
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled rejection at:", promise, "reason:", reason);
});
I treat these global handlers as a last line of defense, not a substitute for proper local error handling. If I’m relying on them to catch everything, I’ve already lost visibility into where things are actually failing.
Internal Working: How Error Propagation Works
Under the hood, when JavaScript throws an error, the engine unwinds the call stack looking for the nearest enclosing try/catch block. If it doesn’t find one, the error propagates all the way up to the global scope, which is why unhandled errors in the browser show up in the console and, in Node.js, can crash the process.
This unwinding process matters for performance too — engines like V8 optimize functions differently when they contain try/catch blocks versus when they don’t, although modern V8 versions have significantly closed this gap compared to older JavaScript engines. I don’t avoid try/catch for performance reasons anymore; the difference is negligible in most real applications.
For asynchronous code, the event loop plays a role. When an error is thrown inside a .then() callback or after an await, it’s converted into a rejected Promise. That rejection then propagates through the Promise chain until it hits a .catch() or an await wrapped in try/catch. If it never does, it becomes an “unhandled rejection,” which is why the unhandledrejection event exists.
Practical, Real-World Patterns
Retry Logic with Exponential Backoff
async function fetchWithRetry(url, retries = 3, delay = 500) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`Status ${response.status}`);
return await response.json();
} catch (error) {
if (attempt === retries) throw error;
console.warn(`Attempt ${attempt} failed, retrying...`);
await new Promise((r) => setTimeout(r, delay * attempt));
}
}
}
Wrapping Errors with Context
I like to add context as an error bubbles up so I know exactly where it originated without losing the original cause.
async function getUserOrders(userId) {
try {
return await fetchOrders(userId);
} catch (error) {
throw new Error(`Failed to load orders for user ${userId}: ${error.message}`, {
cause: error,
});
}
}
The second argument, { cause: error }, is the modern Error cause chaining feature. It lets me inspect error.cause later without losing the original stack trace.
Debugging Tips I Rely On
- I always log
error.stack, not justerror.message, during development. - I use descriptive, actionable error messages — “Insufficient funds: balance is 100, requested 150” is far more useful than “Invalid operation.”
- I avoid catching errors I can’t meaningfully handle. If I can’t do anything useful with an error, I let it propagate.
- I use browser DevTools’ “Pause on exceptions” feature to catch errors exactly where they happen.
- In production, I integrate an error-tracking service (like Sentry) so I get notified the moment something breaks for real users.
Common Mistakes to Avoid
- Swallowing errors silently — an empty
catch {}block is one of the most dangerous things I can write. - Catching too broadly — wrapping huge chunks of code in one
try/catchmakes it hard to know what actually failed. - Forgetting
awaitinsidetry— if I don’tawaita promise inside atryblock, thecatchwon’t catch its rejection. - Not handling Promise rejections — every promise chain needs a
.catch()or must be inside atry/catchwithawait. - Throwing non-Error values —
throw "Something broke"loses the stack trace that makes debugging possible.
Security Considerations
I’m careful never to leak sensitive information in error messages that get shown to end users or returned from APIs. Stack traces, database queries, and internal file paths should never reach the client in production. I always log the full error server-side but return a generic, safe message to the user.
app.use((error, req, res, next) => {
console.error(error.stack); // full detail, server logs only
res.status(500).json({ message: "Something went wrong. Please try again." });
});
FAQs
Q: What’s the difference between throw and console.error? A: console.error just logs a message to the console — it doesn’t stop execution. throw actually interrupts the normal flow of the program and starts the process of looking for a catch block.
Q: Can I catch a SyntaxError from my own code? A: No. If the JavaScript file itself has a syntax error, it fails to parse and nothing runs at all, so there’s no try/catch to catch it. You can only catch SyntaxError from things like JSON.parse() or eval().
Q: Should I use finally to hide loading spinners? A: Yes, this is one of my favorite uses for finally — it runs regardless of success or failure, so it’s perfect for cleanup logic like hiding loaders or closing modals.
Q: Is it bad to use try/catch for control flow? A: Generally yes. Errors should represent exceptional situations, not routine logic. Using exceptions for regular control flow makes code harder to read and can hurt performance in some engines.
Q: What is AggregateError? A: It’s a built-in error type introduced with Promise.any() that bundles multiple errors together when all promises in the group reject.
Summary and Key Takeaways
Error handling isn’t an afterthought — it’s a core part of how I design software now. Here’s what I always keep in mind:
- Use
try/catch/finallyfor synchronous andasync/awaitcode. - Use
.catch()for Promise chains. - Create custom error classes to distinguish failure types.
- Add global handlers as a safety net, not a primary strategy.
- Never swallow errors silently, and never leak sensitive details to end users.
- Add context when rethrowing errors so debugging is easier down the line.
Getting comfortable with error handling early on will save you countless hours of confused debugging later, and it’s one of the clearest signs of a mature JavaScript codebase.