Last year I inherited a codebase where every catch block looked like this:

catch (error) {
    console.log('error');
}

That's it. No error message. No stack trace. Just the string 'error'. When something broke in production—and things broke constantly—we had no idea what was happening. The logs were useless. Users would report "the button doesn't work" and we'd spend hours guessing.

I've since become obsessive about error handling. Not because it's fun, but because the alternative is so painful. Here's what I've learned.

Not All Errors Are Equal

First thing to understand: there are two fundamentally different kinds of errors.

Operational errors are expected. The network times out. The user enters an invalid email. The API returns a 404. These aren't bugs—they're just things that happen. Your code should handle them gracefully.

Programmer errors are bugs. You called a function with the wrong arguments. You accessed a property on undefined. These mean your code is broken and needs to be fixed.

Why does this distinction matter? Because you handle them differently. Operational errors need fallback UI and retry logic. Programmer errors need to be logged and fixed—showing a nice error message doesn't help if the underlying code is broken.

Custom Error Classes

JavaScript's built-in Error is pretty bare-bones. When an error bubbles up to your global handler, error.message might just say "Request failed". Not helpful.

I like to create custom error classes that carry more context:

class AppError extends Error {
    constructor(message, statusCode, isOperational = true) {
        super(message);
        this.statusCode = statusCode;
        this.isOperational = isOperational;
        this.name = this.constructor.name;
        Error.captureStackTrace(this, this.constructor);
    }
}

class ValidationError extends AppError {
    constructor(message) {
        super(message, 400);
    }
}

class NotFoundError extends AppError {
    constructor(resource) {
        super(`${resource} not found`, 404);
    }
}

// Usage
function getUser(id) {
    const user = db.find(id);
    if (!user) {
        throw new NotFoundError('User');
    }
    return user;
}

Now when I catch an error, I can check error.isOperational. If true, I show the user a helpful message. If false, I show a generic "Something went wrong" and log the full stack trace for debugging.

The Async Wrapper Trick

If you're using async/await, you're probably writing a lot of try/catch blocks. It gets repetitive:

async function loadUser() {
    try {
        const user = await fetchUser();
        // ... 
    } catch (e) {
        handleError(e);
    }
}

async function loadPosts() {
    try {
        const posts = await fetchPosts();
        // ...
    } catch (e) {
        handleError(e);
    }
}

I use a wrapper function to reduce this boilerplate:

const catchAsync = (fn) => (...args) => {
    return fn(...args).catch(error => {
        console.error('Caught:', error);
        showErrorNotification(error.message);
    });
};

// Now the business logic stays clean
const loadUser = catchAsync(async (userId) => {
    const user = await fetchUser(userId);
    renderProfile(user);
});

const loadPosts = catchAsync(async (userId) => {
    const posts = await fetchPosts(userId);
    renderPosts(posts);
});

The actual logic is now pure—just the happy path. All the error handling is centralized in one place.

Global Safety Nets

No matter how careful you are, something will slip through. A third-party library will throw. A browser extension will inject broken code. You need a last line of defense.

// Catch synchronous errors
window.addEventListener('error', (event) => {
    console.error('Uncaught error:', event.error);
    logToService(event.error);
    showCrashDialog();
});

// Catch unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
    console.error('Unhandled rejection:', event.reason);
    logToService(event.reason);
    // Maybe show a less aggressive notification
});

The unhandledrejection one is especially important. Forgot an await? Didn't catch a promise? This is where you'll find out. In development, I make this throw a big red alert so I notice immediately.

Retry Logic for Flaky Networks

Mobile networks are unreliable. Sometimes a request fails just because someone walked into an elevator. Failing immediately is bad UX.

async function fetchWithRetry(url, attempts = 3) {
    for (let i = 0; i < attempts; i++) {
        try {
            const response = await fetch(url);
            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            return await response.json();
        } catch (error) {
            const isLastAttempt = i === attempts - 1;
            if (isLastAttempt) throw error;
            
            // Exponential backoff: 1s, 2s, 4s
            const delay = 1000 * Math.pow(2, i);
            console.log(`Attempt ${i + 1} failed, retrying in ${delay}ms`);
            await new Promise(r => setTimeout(r, delay));
        }
    }
}

I use this for anything important. The user doesn't see the retries—they just see that it eventually worked. Much better than immediately showing an error for a temporary glitch.

Putting It Together

Good error handling is layered:

  1. Local: Specific try/catch for specific recovery ("Invalid password, please try again")
  2. Wrapper: Generic catch for unexpected failures in async code
  3. Global: window.onerror as the last safety net

The goal isn't to prevent errors—that's impossible. The goal is to know when they happen, give users a decent experience anyway, and have enough information to fix things quickly.

Trust me, future you will be grateful when you're debugging at 2am and the logs actually tell you what went wrong.

← Back to JavaScript Articles

Back to Home