Error Handling Patterns in Large JavaScript Apps
Reviewed & published by Brayan K
Error handling patterns are reusable strategies — such as try/catch, custom error classes, and centralized handlers — for catching, classifying, and recovering from failures so large JavaScript apps stay reliable.
Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
Master professional error handling strategies for enterprise-scale applications
What You'll Learn
- Layered try/catch boundaries
- Centralized error handlers
- Defensive programming techniques
- Global Promise rejection handling
- Custom error classes
- Retry logic with exponential backoff
💡 Running Code Locally: While this online editor runs real JavaScript, some advanced examples may have limitations. For the best experience:
- Download Node.js to run JavaScript on your computer
- Use your browser's Developer Console (Press F12) to test code snippets
- Create a .html file with <script> tags and open it in your browser
Error handling in small scripts is simple: wrap things in try/catch and log a message. But once you start building real applications — dashboards, ecommerce sites, admin tools, mobile apps, AI tools — everything becomes more complex.
Why Error Handling Changes in Big Apps
- many API calls happening at once
- shared state across components
- multiple async operations
- user-driven events
- dynamic rendering
- background tasks
- cross-origin requests
- server-side + client-side logic
Pattern 1 — Layered try/catch
Instead of wrapping EVERYTHING in try/catch, you wrap logical boundaries.
async function initializeApp() {
try {
await loadUser();
await loadDashboard();
await connectToSocket();
} catch (err) {
handleGlobalError(err);
}
}Pattern 2 — Centralized Error Handler
Instead of writing console.error everywhere, use a shared handler.
Pattern 3 — Defensive Programming
Instead of assuming everything exists, verify it.
const el = document.querySelector("#profile");
if (!el) return; // fail early
// Check API responses
if (!data || !data.user) throw new Error("Malformed response");
// Check input types
if (typeof callback !== "function") return;Pattern 4 — Guarding Async Code
In big apps, async errors often get lost.
❌ Wrong
async function load() {
const data = await fetchData(); // if this rejects → silent failure
}✔ Right
async function load() {
try {
const data = await fetchData();
} catch (err) {
handleError(err, "load");
}
}Even better — wrap async functions in a helper:
async function safe(fn) {
try {
return [await fn(), null];
} catch (err) {
return [null, err];
}
}
// Usage:
const [data, error] = await safe(() => fetchData());
// No crashes, no lost errors.Pattern 5 — Global Promise Rejection Handler
Large apps must handle unhandled Promise rejections:
window.addEventListener("unhandledrejection", (event) => {
handleError(event.reason, "Unhandled Promise");
});Pattern 6 — Error Boundaries (React)
class ErrorBoundary extends React.Component {
state = { hasError: false };
componentDidCatch(error, info) {
this.setState({ hasError: true });
}
render() {
return this.state.hasError
? <FallbackUI />
: this.props.children;
}
}Pattern 7 — Custom Error Classes
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
class ApiError extends Error {
constructor(message, status) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
// Usage:
try {
throw new ApiError("Not Found", 404);
} catch (err) {
if (err instanceof ApiError) {
console.error("API error:", err.status);
}
}Worked Example — Four Patterns in One Program
Patterns 1, 2, 3 and 7 are designed to be used together, so here they are in a single program you can actually run. A signup form goes in; a custom error class, a defensive check, one boundary try/catch and one shared handler decide what comes out. Read the comments, run it, then change an input and run it again.
// WORKED EXAMPLE - three patterns working together in one runnable program:
// Pattern 7 (custom error classes) + Pattern 3 (defensive checks)
// + Pattern 2 (one centralized handler that decides what happens next).
// It is all synchronous, so it runs straight down the page.
// ---------- Pattern 7: custom error classes ----------
// "extends Error" gives you a real error (message + stack trace) and lets you
// bolt on extra fields you can branch on later.
class ValidationError extends Error {
constructor(message, field) {
super(message); // sets this.message
this.name = "ValidationError"; // logs read "ValidationError: ..." not "Error: ..."
this.field = field; // extra data: WHICH field was wrong
}
}
class ApiError extends Error {
constructor(message, status) {
super(message);
this.name = "ApiError";
this.status = status; // extra data: the HTTP status code
}
}
// ---------- Pattern 2: one centralized handler ----------
// Every catch block calls this one function. One place decides what the user
// is told and what the app does next, so the rules never drift apart.
function handleError(error, context) {
if (error instanceof ValidationError) {
console.log("[" + context + "] fix the '" + error.field + "' field: " + error.message);
return "ASK_USER_AGAIN"; // the caller can act on this string
}
if (error instanceof ApiError && error.status >= 500) {
console.log("[" + context + "] server said " + error.status + " - safe to retry later");
return "RETRY_LATER";
}
console.log("[" + context + "] unexpected " + error.name + ": " + error.message);
return "GIVE_UP"; // anything you did not plan for
}
// ---------- Pattern 3: defensive programming ----------
// Check the shape of the data BEFORE you use it, and throw early with a
// specific error. A bad signup then fails here, not three screens later.
function createAccount(form) {
if (!form || typeof form !== "object") {
throw new ValidationError("no form data was sent", "form");
}
if (!form.email || !form.email.includes("@")) {
throw new ValidationError("email must contain @", "email");
}
if (typeof form.age !== "number") {
throw new ValidationError("age must be a number, not " + typeof form.age, "age");
}
if (form.email.endsWith("@blocked.test")) {
throw new ApiError("signup service rejected the domain", 503);
}
return { id: 101, email: form.email, age: form.age }; // the happy path
}
// ---------- Pattern 1: one try/catch around a whole job ----------
// You wrap the logical boundary (a signup attempt), not every single line.
function trySignup(form) {
try {
const user = createAccount(form);
console.log("created account #" + user.id + " for " + user.email);
return "OK";
} catch (err) {
return handleError(err, "signup"); // all failures funnel through one door
}
}
// ---------- Run it on good and bad input ----------
console.log(trySignup({ email: "[email protected]", age: 31 })); // valid
console.log(trySignup({ email: "sam.example.com", age: 31 })); // no @
console.log(trySignup({ email: "[email protected]", age: "31" })); // age is a string
console.log(trySignup({ email: "[email protected]", age: 20 })); // server says 503
console.log(trySignup(null)); // nothing at all
// ✅ Expected output:
// created account #101 for [email protected]
// OK
// [signup] fix the 'email' field: email must contain @
// ASK_USER_AGAIN
// [signup] fix the 'age' field: age must be a number, not string
// ASK_USER_AGAIN
// [signup] server said 503 - safe to retry later
// RETRY_LATER
// [signup] fix the 'form' field: no form data was sent
// ASK_USER_AGAIN🎯 Your Turn — Checkout Errors
Now you write the parts that matter. A checkout has to tell "the user's basket is empty" apart from "the payment provider is down", because those need completely different messages. Everything else is written for you — fill in the three blanks marked ___.
// 🎯 YOUR TURN - fill in the three blanks marked with ___
// A checkout must tell "the user typed something wrong" apart from
// "the payment provider is down". The handler is written for you.
// 1) Finish the custom error class so it behaves like a real error.
class PaymentError extends ___ { // 👉 replace ___ with Error
constructor(message, provider) {
super(message);
this.name = "PaymentError";
this.provider = provider; // which provider failed
}
}
class CartError extends Error { // this one is done for you
constructor(message) {
super(message);
this.name = "CartError";
}
}
// 2) Defensive check: throw a CartError when the basket is empty.
function checkout(cart, provider) {
if (!Array.isArray(cart) || cart.length === 0) {
throw ___; // 👉 replace ___ with: new CartError("your basket is empty")
}
if (provider === "offline") {
throw new PaymentError("provider unreachable", provider);
}
return cart.reduce((sum, item) => sum + item.price, 0); // total price
}
// 3) Centralized handler: branch on the error TYPE, never on message text.
function handleCheckoutError(error) {
if (error instanceof ___) { // 👉 replace ___ with CartError
return "Add something to your basket first.";
}
if (error instanceof PaymentError) {
return "Payment via " + error.provider + " is down. Try another card.";
}
return "Something went wrong.";
}
// The boundary try/catch - already written
function attempt(cart, provider) {
try {
const total = checkout(cart, provider);
return "Charged " + total.toFixed(2);
} catch (err) {
return handleCheckoutError(err);
}
}
console.log(attempt([{ price: 12.5 }, { price: 4.5 }], "stripe"));
console.log(attempt([], "stripe"));
console.log(attempt([{ price: 9.99 }], "offline"));
// ✅ Expected output once the blanks are filled:
// Charged 17.00
// Add something to your basket first.
// Payment via offline is down. Try another card.Pattern 8 — Retry Logic with Backoff
async function retry(fn, retries = 3, delay = 500) {
try {
return await fn();
} catch (err) {
if (retries === 0) throw err;
await new Promise(res => setTimeout(res, delay));
return retry(fn, retries - 1, delay * 2); // exponential backoff
}
}
// Usage:
const data = await retry(() => fetch("/api/data").then(r => r.json()));
// Dramatically reduces user errors for poor mobile or public WiFi networks.Pattern 9 — AbortController for Timeouts
function fetchWithTimeout(url, ms = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
return fetch(url, { signal: controller.signal })
.finally(() => clearTimeout(timer));
}
// Handle timeout:
try {
const res = await fetchWithTimeout("/api/data", 3000);
} catch (error) {
if (error.name === "AbortError") {
console.error("Request timed out");
}
}Pattern 10 — Circuit Breaker
If an API repeatedly fails, the app temporarily stops calling it:
class CircuitBreaker {
constructor(failureLimit = 3, resetTime = 5000) {
this.failures = 0;
this.state = "CLOSED";
this.failureLimit = failureLimit;
this.resetTime = resetTime;
}
async call(fn) {
if (this.state === "OPEN") {
throw new Error("Circuit is open");
}
try {
const result = await fn();
this.failures = 0;
return result;
} catch (err) {
this.failures++;
if (this.failures >= this.failureLimit) {
this.state = "OPEN";
setTimeout(() => (this.state = "CLOSED"), this.resetTime);
}
throw err;
}
}
}🎯 Mini-Challenge — The [data, error] Wrapper
Pattern 4 showed the Go-style [data, error] tuple. Build the synchronous version of it yourself. Write safeJson(text): it tries to parse a JSON string and always returns a two-item array, so the caller never needs its own try/catch.
- On success return [theObject, null].
- On failure return [null, theError].
- Loop over the three inputs and print a line for each.
// 🎯 MINI-CHALLENGE: a [data, error] wrapper (Pattern 4)
//
// 1. Write safeJson(text) that tries JSON.parse(text) inside a try/catch.
// 2. On success return [theObject, null]. On failure return [null, theError].
// 3. Loop over the inputs below. Destructure with: const [data, error] = ...
// 4. For each input print:
// - when error is null: "ok: " + data.name
// - otherwise: "failed: " + error.name
// Print error.name, NOT error.message - every engine words the message
// differently, so message text is not something you can rely on.
const inputs = ['{"name":"Ada"}', 'not json', '{"name":"Linus"}'];
// your code here
// ✅ Expected output:
// ok: Ada
// failed: SyntaxError
// ok: LinusWhat You Learned
- Why error handling is different in large apps
- Async error guarding
- Global error listeners
- Error boundaries in React
- Timeout handling with AbortController
- Circuit breaker pattern
Practice quiz
Why does the lesson recommend centralized error handling for big apps?
- It makes the code shorter only
- It removes the need for try/catch entirely
- So problems don't crash the UI or silently break features
- It speeds up network requests
Answer: So problems don't crash the UI or silently break features. Large apps use structured, centralized patterns so a single missing await or rejected Promise doesn't crash the UI or silently break things.
What is the idea behind 'layered try/catch'?
- Wrap logical boundaries instead of wrapping everything
- Wrap absolutely everything in try/catch
- Never use try/catch
- Only catch errors in the UI layer
Answer: Wrap logical boundaries instead of wrapping everything. Instead of wrapping every line, you wrap logical boundaries (e.g. initializeApp) so a failure falls back to a controlled error screen.
What is the benefit of a centralized error handler function?
- It hides all errors from developers
- It makes errors disappear
- It only works in React
- It produces consistent debugging info and prevents duplicated code
Answer: It produces consistent debugging info and prevents duplicated code. A shared handler like handleError(error, context) gives consistent debugging info and avoids scattered console.error calls.
What does defensive programming encourage?
- Assume everything exists
- Verify things exist before using them and fail early
- Catch errors only at the top level
- Avoid checking input types
Answer: Verify things exist before using them and fail early. Defensive programming verifies elements, API responses, and input types exist (failing early) so small bugs don't become runtime crashes.
In the safe() async helper, what does it return?
- A [data, error] tuple (Go-style)
- Just the data
- A boolean
- Only the error
Answer: A [data, error] tuple (Go-style). The safe(fn) helper returns a [data, error] tuple so you can handle errors without try/catch everywhere.
Which event lets a large app handle unhandled Promise rejections globally?
- window.addEventListener('error')
- process.on('exit')
- window.addEventListener('unhandledrejection')
- window.onload
Answer: window.addEventListener('unhandledrejection'). Listening for 'unhandledrejection' catches API failures, library issues, and missing .catch() chains across the app.
What do React Error Boundaries allow?
- Faster rendering of all components
- Components to fail without breaking the entire UI
- Automatic retries of failed fetches
- Global Promise handling
Answer: Components to fail without breaking the entire UI. Error boundaries use componentDidCatch to render a fallback UI, letting a component fail without breaking the whole interface.
Why create custom error classes like ApiError and ValidationError?
- To make errors throw faster
- To avoid using try/catch
- To hide error messages
- To distinguish error types and catch specific kinds with instanceof
Answer: To distinguish error types and catch specific kinds with instanceof. Custom error classes let you distinguish API errors from UI errors and catch specific kinds (e.g. err instanceof ApiError).
How does exponential backoff change the wait time between retries?
- It keeps the delay constant
- It doubles the wait each retry (500ms then 1s then 2s)
- It halves the delay each time
- It waits a random amount with no pattern
Answer: It doubles the wait each retry (500ms then 1s then 2s). The retry helper uses delay * 2, doubling the wait between attempts (500ms then 1s then 2s) to prevent server overload.
What does the Circuit Breaker pattern do when an API repeatedly fails?
- Retries forever
- Deletes the API endpoint
- Temporarily stops calling it by opening the circuit
- Switches to a different programming language
Answer: Temporarily stops calling it by opening the circuit. When failures hit the limit, the breaker sets state to OPEN and temporarily stops calls, resetting to CLOSED after resetTime.
Continue this course
- Previous: Building Custom APIs with Fetch & Async Logic
- Next: Regular Expressions Advanced Techniques — Master lookaheads, named groups, and real-world regex patterns
- Quick reference: JavaScript cheat sheet