Error handling is easy when the code is small.
Call a function, wrap it in try/catch, log the error, move on.
Real applications are different. Errors can appear inside promises, timers, event handlers, API calls, rendering code, or somewhere several layers below the function that started the operation.
The difficult part is rarely writing catch. It is deciding where an error belongs, who should handle it, and what should happen next.
This is Part 5 of the JS Coding Techniques series.
Previous parts:
Part 2: 20 Modern JavaScript Tricks for Cleaner Everyday Code
Part 3: 10 Debounce and Throttle Patterns Every JS Developer Should Know
Part 4: 10 JavaScript Async Concurrency Tips and Common Pitfalls
Now let’s look at ten error handling patterns that are useful in everyday JavaScript.
1. Keep async error handling in one place
Promise chains have their own error handling:
fetchUser(userId)
.then((user) => {
renderUser(user);
})
.catch((error) => {
console.error("Failed to load user:", error);
});There is nothing wrong with this approach.
But once a function already uses async/await, a try/catch block usually makes the control flow easier to follow.
async function loadUser(userId) {
try {
const user = await fetchUser(userId);
renderUser(user);
} catch (error) {
console.error("Failed to load user:", error);
}
}This becomes especially useful when several operations belong to the same task.
async function loadDashboard(userId) {
try {
const user = await fetchUser(userId);
const projects = await fetchProjects(user.id);
renderDashboard({ user, projects });
} catch (error) {
console.error("Failed to load dashboard:", error);
}
}One block now represents one operation from the application’s point of view.
The important detail is that rejected promises still need a consumer. If an async function rejects and nobody awaits it with error handling or attaches .catch(), the rejection becomes unhandled.
So this is risky:
loadDashboard(userId);If the caller owns the failure, handle it there:
loadDashboard(userId).catch((error) => {
reportError(error);
});A useful rule is simple: every promise should eventually have an owner responsible for its failure.
2. try/catch does not follow asynchronous callbacks
This looks reasonable at first:
try {
setTimeout(() => {
throw new Error("Something went wrong");
}, 100);
} catch (error) {
console.error("Caught:", error);
}But the catch block never receives that error.
The callback runs later, after the original try block has already finished.
If the callback itself can throw, handle the error inside that execution path:
setTimeout(() => {
try {
runBackgroundTask();
} catch (error) {
console.error("Background task failed:", error);
}
}, 100);The same idea applies to many callback-based APIs.
button.addEventListener("click", () => {
try {
updateAccount();
} catch (error) {
showErrorMessage(error);
}
});Promises behave differently because rejection travels through the promise chain:
Promise.resolve()
.then(() => {
throw new Error("Failed");
})
.catch((error) => {
console.error("Caught:", error);
});When working with asynchronous code, always ask one question:
Where will an error from this callback or promise go?
If there is no clear answer, you probably have a missing error path.
3. Add a global fallback for unhandled promise rejections
Local error handling should do most of the work.
Still, applications benefit from a final safety net for failures that escaped their normal handlers.
In the browser:
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled promise rejection:", event.reason);
reportError(event.reason);
});You can also observe uncaught synchronous errors:
window.addEventListener("error", (event) => {
reportError(event.error ?? event.message);
});For Node.js:
process.on("unhandledRejection", (reason) => {
console.error("Unhandled promise rejection:", reason);
reportError(reason);
});These handlers should not become your normal application architecture.
Their purpose is to catch failures that slipped through everything else and make sure you know about them.
Logging only to the console is often not enough in production. Once the application runs on someone else’s machine or server, you need enough context to investigate what happened.
That may include the stack trace, application version, operation being performed, request ID, or other non-sensitive diagnostic information.
Think of global handlers as the last alarm, not the first line of defense.
4. Create error classes for failures you need to distinguish
Throwing Error everywhere works until callers need to react differently to different failures.
Consider validation and network failures.
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
class HttpError extends Error {
constructor(message, status) {
super(message);
this.name = "HttpError";
this.status = status;
}
}Now a function can communicate more than a text message:
function validateUser(user) {
if (!user.name?.trim()) {
throw new ValidationError(
"Name is required",
"name"
);
}
}The caller can respond to the actual error type:
try {
validateUser(formData);
await saveUser(formData);
} catch (error) {
if (error instanceof ValidationError) {
showFieldError(error.field, error.message);
return;
}
throw error;
}Notice the final throw error.
This matters.
If the current layer knows how to handle a validation error but has no idea what to do with an unexpected database or network failure, it should not silently consume it.
Custom errors are most useful when they carry information the caller can act on.
5. Preserve the original error with cause
Sometimes a low-level error needs more context before it moves up the stack.
Imagine an API request fails:
async function fetchProfile(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
throw new Error("Could not load user profile", {
cause: error,
});
}
}The higher-level message explains what the application was trying to do:
Could not load user profileBut the original error is still available through:
error.causeThis is much better than replacing the original failure with a new unrelated error.
Without cause, code like this loses useful context:
catch {
throw new Error("Request failed");
}A good error often needs two pieces of information:
What operation failed?
Why did it fail?Error.cause gives you a clean way to preserve both.
6. Decide when failure is exceptional and when it is normal
Not every unsuccessful operation deserves an exception.
Suppose a lookup may legitimately return nothing.
You could write:
function findUser(id) {
const user = users.find((user) => user.id === id);
if (!user) {
throw new Error("User not found");
}
return user;
}But if “not found” is an expected result, returning null may describe the API more clearly:
function findUser(id) {
return users.find((user) => user.id === id) ?? null;
}The caller can then handle that branch normally:
const user = findUser(userId);
if (!user) {
showEmptyState();
return;
}
showUser(user);Compare that with a function where failure really prevents the requested operation from continuing:
async function chargeOrder(order) {
const response = await paymentClient.charge(order);
if (!response.ok) {
throw new PaymentError(
"Payment could not be completed",
response.status
);
}
return response.receipt;
}There is no universal rule that business failures must return values while programming failures must throw.
The better question is:
Is this result part of the function’s expected contract, or does it mean the operation failed?
Choose the API based on that distinction, then keep it consistent.
7. Never catch an error just to make it disappear
This is one of the easiest bugs to write:
try {
await saveSettings(settings);
} catch (error) {
// Ignore
}The application continues as if the save worked.
Later, someone reports that their settings keep disappearing.
Now you have two problems: the original failure and no evidence explaining it.
If the current layer can recover, recover:
try {
await saveSettings(settings);
} catch (error) {
showMessage("Settings could not be saved.");
reportError(error);
}If it cannot recover, add useful context and propagate the failure:
try {
await saveSettings(settings);
} catch (error) {
throw new Error("Failed to save account settings", {
cause: error,
});
}There are cases where ignoring an error is intentional.
For example, cleanup code may attempt to remove a temporary resource that no longer exists.
try {
await removeTemporaryFile(path);
} catch (error) {
if (!(error instanceof FileNotFoundError)) {
throw error;
}
}That is not swallowing an error blindly. It is explicitly handling a failure you already understand.
A catch block should have a reason to exist.
8. Use Error Boundaries for React rendering failures
React applications have another category of failure: a component can throw while React is rendering the UI.
An Error Boundary can isolate that failure and replace the broken part of the tree with fallback UI.
import { Component } from "react";
class ErrorBoundary extends Component {
state = {
hasError: false,
};
static getDerivedStateFromError() {
return {
hasError: true,
};
}
componentDidCatch(error, info) {
reportError(error, {
componentStack: info.componentStack,
});
}
render() {
if (this.state.hasError) {
return <p>This section could not be loaded.</p>;
}
return this.props.children;
}
}Then wrap the part of the interface you want to isolate:
function App() {
return (
<>
<Header />
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
</>
);
}If Dashboard or one of its descendants throws during rendering, React can show the fallback instead of losing that entire section of the interface.
But an Error Boundary is not a universal try/catch.
For example, errors from an event handler still need their own handling:
async function handleSave() {
try {
await saveDocument();
} catch (error) {
reportError(error);
setMessage("Could not save the document.");
}
}Use Error Boundaries for the failures they are designed to isolate, and handle operational async failures where those operations happen.
9. Treat Node.js process-level handlers as emergency exits
A Node.js service can listen for errors that escaped normal application handling.
process.on("uncaughtException", (error) => {
console.error("Uncaught exception:", error);
reportError(error);
process.exit(1);
});You may also observe unhandled promise rejections:
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
reportError(reason);
});The important part is what you do not put there.
Do not try to continue normal business logic from uncaughtException.
An uncaught exception means the application reached a state its normal control flow did not account for. Depending on what failed, internal state may no longer be trustworthy.
Process-level handlers are therefore useful for final logging, reporting, and controlled shutdown behavior.
In production, a process manager, container platform, or orchestration system can then start a fresh process.
Your request handlers should still deal with expected failures locally.
app.get("/users/:id", async (req, res, next) => {
try {
const user = await getUser(req.params.id);
res.json(user);
} catch (error) {
next(error);
}
});Process-level hooks are the safety net underneath that architecture, not a replacement for it.
10. Use optional chaining for missing data, not broken assumptions
Optional chaining and nullish coalescing can prevent unnecessary failures when missing data is expected.
const displayName =
user?.profile?.displayName ?? "Anonymous";This is much cleaner than manually checking every level:
let displayName = "Anonymous";
if (
user &&
user.profile &&
user.profile.displayName
) {
displayName = user.profile.displayName;
}But defensive syntax can also hide bugs when used too aggressively.
Consider:
const total = order?.payment?.total ?? 0;Is a missing payment really supposed to mean $0?
Maybe.
Or perhaps every completed order must have payment information, and its absence indicates corrupted or incomplete data.
In that case, failing explicitly is more useful:
function getOrderTotal(order) {
if (!order.payment) {
throw new Error("Order is missing payment data");
}
return order.payment.total;
}Optional chaining answers:
What should happen if this value is missing?
Error handling answers a different question:
What should happen if this operation cannot continue correctly?
They work well together, but they solve different problems.
A practical error handling strategy
The individual patterns matter, but consistency matters more.
For most applications, a useful approach looks something like this:
Expected missing value
↓
return null / undefined / Result
Recoverable operation failure
↓
catch locally and show fallback
Unexpected lower-level failure
↓
add context + rethrow
Rendering failure
↓
Error Boundary
Unhandled application failure
↓
global reporting
Unrecoverable Node.js process failure
↓
log → report → shutdown → restartYou do not need try/catch around every function.
You need clear ownership of failure.
A parsing function may throw because invalid input makes its result impossible. A search function may return null because finding nothing is expected. A UI handler may catch a network error because it knows how to tell the user what happened.
Those choices form the error-handling contract of your application.
Final thoughts
Good error handling is mostly invisible when everything works.
Its value appears when something doesn’t.
Instead of a blank screen, the user gets a fallback. Instead of an unexplained production bug, you get a stack trace with context. Instead of every function inventing its own failure convention, callers know whether to expect null, a rejected promise, or a specific error type.
The goal is not to catch every possible error.
It is to make failures predictable.
Catch errors where you can actually recover from them. Add context when you cannot. Preserve the original cause, avoid silent catch blocks, and keep global handlers as the final safety net.
That gives you something much more useful than code that simply “doesn’t crash”: code that tells you what went wrong when it eventually does.


