Error handling in JavaScript: try/catch and beyond
Errors are inevitable in any JavaScript application. Whether it’s a typo in your code, an unexpected API response, or a missing file, your program will encounter situations it wasn’t prepared for. Good error handling turns a crash into a controlled response; you catch exceptions where they happen, decide what recovery makes sense, and surface enough context to fix the underlying problem.
You’ll use try/catch blocks to intercept synchronous exceptions, throw custom errors when something goes wrong, and create reliable error-handling strategies for your applications.
We’ll work through the JavaScript Error class hierarchy, the async-friendly equivalents using Promise.catch() and async/await, and patterns for surfacing errors in long-running Node services. Along the way you’ll see how to write small custom error subclasses that capture the context your debugger needs, why console.error(err) beats console.error(err.message), and how to keep stack traces useful when errors cross async boundaries. For related foundations see the iterators protocol guide.
Understanding errors in JavaScript
Before we dive into handling errors, let’s understand what happens when something goes wrong. When JavaScript encounters an error, it throws an exception. If that exception isn’t caught, it propagates up the call stack until it reaches the top level, where it typically crashes your program.
// This will throw an error and crash the program
const result = someUndefinedVariable + 5;
// ReferenceError: someUndefinedVariable is not defined
Errors can occur for many reasons:
- ReferenceError: Trying to access a variable that doesn’t exist
- TypeError: Using a value in an unexpected way
- SyntaxError: Code that violates JavaScript’s grammar
- RangeError:数值超出允许范围
- NetworkError: API calls or file operations fail
The try/catch statement
The fundamental way to handle errors in JavaScript is the try/catch statement. Here’s how it works:
try {
// Code that might throw an error
const data = JSON.parse(userInput);
console.log('Parsed successfully:', data);
} catch (error) {
// Code that runs when an error occurs
console.log('Failed to parse:', error.message);
}
The code inside the try block executes normally. If an error occurs, JavaScript immediately jumps to the catch block, skipping any remaining code in the try block. This is why you rarely see defensive null checks inside a try block—the catch path handles the failure case, so the success path stays clean. When the error fires, the runtime constructs an Error object and hands it to the catch clause as its single argument.
The error object
When an error is caught, you receive an Error object with useful properties:
try {
somethingRisky();
} catch (error) {
console.log(error.name); // Error type: "ReferenceError", "TypeError", etc.
console.log(error.message); // Human-readable description
console.log(error.stack); // Stack trace for debugging
}
The name tells you the error type, message gives a human-readable description, and stack provides the full call trace. Logging all three during development lets you pin down exactly where things went wrong—relying on message alone often hides the file and line number you need.
Try/catch/finally
JavaScript provides a third clause: finally. Code in the finally block runs regardless of whether an error occurred:
try {
const data = fetchData();
console.log('Data fetched:', data);
} catch (error) {
console.log('Error fetching data:', error.message);
} finally {
// This always runs - cleanup code goes here
console.log('Cleaning up...');
}
The finally block is perfect for cleanup tasks like closing files, releasing resources, or stopping loading indicators. It runs even when the try block returns or the catch block re-throws, so you can rely on it for teardown without duplicating cleanup code in every exit path.
Throwing custom errors
Sometimes your own functions need to signal failure with more precision than a generic Error. Use throw to halt execution and let the caller decide how to recover—for example:
function divide(a, b) {
if (b === 0) {
throw new Error('Cannot divide by zero');
}
return a / b;
}
try {
const result = divide(10, 0);
} catch (error) {
console.log(error.message); // "Cannot divide by zero"
}
You can throw any value, but best practice is to throw Error objects or subclasses. A plain throw 'something broke' loses the stack trace and gives the catcher nothing useful to inspect. Error subclasses let you attach structured context—field names, validation rules, request IDs—so the catch block can decide what to do without string-parsing the message.
// Custom error class
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
function validateAge(age) {
if (typeof age !== 'number') {
throw new ValidationError('age', 'Age must be a number');
}
if (age < 0 || age > 150) {
throw new ValidationError('age', 'Age must be between 0 and 150');
}
}
try {
validateAge('twenty');
} catch (error) {
if (error instanceof ValidationError) {
console.log(`Validation failed for ${error.field}: ${error.message}`);
} else {
throw error; // Re-throw unknown errors
}
}
Async error handling
The same throw/catch patterns that work for synchronous code also apply when promises or async functions fail. The difference is timing: a rejected promise does not throw immediately on the call stack, so an unguarded .then() chain can swallow errors silently. Handling async errors requires attaching a catch handler to every promise chain, or more readably, wrapping awaited calls in try/catch blocks.
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Fetch failed:', error));
With async/await, wrap your code in try/catch. The pattern is the same as synchronous error handling, but the await keyword pauses execution until the promise settles. If it rejects, the catch block receives the error. Returning null or a default value from the catch clause is one option; throwing a domain-specific error is another if the caller needs more context to decide how to recover.
async function getData() {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
return data;
} catch (error) {
console.error('Failed to fetch:', error);
return null; // Or re-throw, or throw a custom error
}
}
Handling multiple async operations
When you have several independent requests to make, firing them sequentially means each one waits for the previous one to finish. Running them in parallel with Promise.allSettled() collects every result, success or failure, so you can process partial data and log errors by index.
async function loadUserData() {
const results = await Promise.allSettled([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/settings').then(r => r.json())
]);
results.forEach((result, index) => {
if (result.status === 'rejected') {
console.error(`Operation ${index} failed:`, result.reason);
}
});
}
Promise.allSettled() waits for all promises to complete (whether successful or failed), giving you the status of each. Unlike Promise.all(), which short-circuits on the first rejection, allSettled lets you collect partial results and log individual failures without losing the successes. This is useful for dashboard-style pages that pull from several independent endpoints.
Decide where errors should stop
Not every error needs the same response. Some should be handled immediately and turned into a friendly message, while others should bubble up so the caller can decide what to do next. Thinking about that boundary early keeps the code from swallowing problems that should have stayed visible. It also helps you choose between logging, retrying, rethrowing, or returning a fallback value with a clearer purpose.
The most useful rule is to keep the handling close to the code that can actually recover. If a function can replace a missing value or retry a flaky call, handling the error there makes sense. If it cannot do anything sensible, it should let the error move upward. That keeps the error story honest and makes the caller responsible for the right level of recovery.
Best practices
Here are some guidelines for effective error handling:
1. Be Specific About Errors
// Bad - catching everything
try {
riskyOperation();
} catch (error) {
console.log('Something went wrong');
}
// Good - handling specific errors
try {
riskyOperation();
} catch (error) {
if (error instanceof TypeError) {
// Handle type errors specifically
} else if (error instanceof RangeError) {
// Handle range errors specifically
} else {
throw error; // Unknown error - let it propagate
}
}
Checking instanceof keeps the catch clause focused. It lets you handle type errors one way, range errors another, and re-throw anything you don’t recognize so it can bubble up to a caller that knows what to do with it.
2. Don’t Swallow Errors
// Bad - hiding errors
try {
doSomething();
} catch (error) {
// Silent failure - bug will be hard to find
}
// Good - at minimum, log the error
try {
doSomething();
} catch (error) {
console.error('Operation failed:', error);
// Then handle or re-throw
}
Silent catch blocks are one of the hardest bugs to track down because nothing breaks visibly—the app just goes wrong in subtle ways. At minimum, log the error with console.error so it appears in the browser console or server logs. Production apps should also send errors to a monitoring service like Sentry or Datadog.
3. Use Error Boundaries (for React)
If you’re building React applications, use error boundaries to catch JavaScript errors in component trees:
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
Summary
Error handling is essential for building reliable JavaScript applications. Key takeaways:
- Use
try/catchto handle synchronous errors - Always include a
finallyblock for cleanup code - Throw custom errors with informative messages
- Handle async errors with
.catch()or try/catch in async functions - Be specific about which errors you catch
- Never silently swallow errors - at minimum, log them
In the next tutorial, we’ll explore how to interact with the DOM to build interactive web pages.
Error handling in everyday code
Good error handling starts with deciding what a failure means for the user. Some errors should stop the current task and show a clear message. Others should be logged and allow the app to continue in a reduced mode. If you make that distinction early, your code becomes calmer and your responses become more useful. You are not trying to hide every problem. You are trying to make the response match the problem.
Custom error classes help when you need to attach extra context. A validation error, a network timeout, and a file parsing error may all be Error objects, but they do not ask for the same recovery path. A small subclass with a name and a few extra fields can make logs and tests much easier to read. It also gives your code a place to record the details you will want during debugging, such as a field name, a request id, or a source filename.
Async code needs the same care as synchronous code, but the failure path is a little less obvious. A promise that rejects, a fetch call that fails, or an awaited function that throws should all be handled close to the place where the app can still react. If the failure crosses several layers, pass it along with enough detail to help the next layer decide what to do. That prevents the catch block from becoming a mystery box.
Logging is useful only when it tells you something you can act on. A raw error message is often not enough, and a stack trace with no context can still leave you guessing. A better log entry includes what the app was trying to do, which input triggered the issue, and whether recovery was possible. That style turns logs into a practical tool rather than a pile of noise.
Finally, test the unhappy path on purpose. A function that always succeeds in tests but fails in production probably never exercised its edge cases. A few checks for bad input, missing data, and async rejection can reveal gaps while the code is still easy to change. That kind of review pays off because error handling is where small oversights tend to become expensive support issues.