Skip to main content
Saved

Error Handling

Category Resilience

Level Advanced

Examples

React, Vue, Native web

Manage and display errors gracefully from failed API requests.

In this article
  1. Problem
  2. Solution
  3. Example
  4. Basic Error Handling
  5. Error Classification and Recovery
  6. Component with Error State
  7. Centralized Error Handler
  8. Retry Logic with Exponential Backoff
  9. Error Boundary Integration
  10. Global Error Handler
  11. Timeout Handling
  12. Graceful Degradation
  13. Benefits
  14. Tradeoffs
  15. Summary
  16. See also

Error Handling

Problem

An error message is bad, but a loading spinner that never stops, because nobody caught the error, is worse. Users wait, refresh, and wait again, without ever finding out that the request failed five seconds ago.

I’ve debugged many production issues where the console showed a clear error but users saw nothing helpful. Network timeouts, 500 errors, and malformed JSON all ended up as silent failures or the dreaded “something went wrong” message that helps nobody.

The inconsistency drives me crazy: one component retries automatically, another gives up immediately, a third shows an error with no retry button. Plenty of errors aren’t even logged, because someone swallowed them with an empty catch block.

Solution

My rule is that every async operation gets explicit error handling. That means wrapping it in try-catch, storing the error in state, and showing users something useful that explains what happened and what they can do.

Different errors also need different responses. A 404 means the resource doesn’t exist, and retrying won’t help, so show “not found.” A network timeout suggests a temporary issue, so offer a retry button. A 401 means the user needs to log in, so redirect them rather than showing a generic error.

Each error gets classified, handled for its category, and logged with enough context to debug later: user ID, action taken, request parameters. The message users see should tell them what to do, like “Check your connection and try again,” rather than cryptic developer output like “Error: undefined is not a function.”

Where you can, it’s better to degrade gracefully than to fail completely. If fresh data fails to load but you have cached data, you can show the cached data with a “Last updated 5 minutes ago” note.

Example

Here’s how I handle errors at different levels, from basic try-catch to classified errors with retry logic and graceful fallbacks.

Basic Error Handling

The basic version wraps async operations in try-catch and lets callers decide how to handle failures:

async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
    return await response.json();
  } catch (error) {
    console.error('Failed to fetch user:', error);
    throw error; // Let caller decide how to handle
  }
}

Error Classification and Recovery

Custom error classes let you tell error types apart and handle each one differently:

class APIError extends Error {
  constructor(message, status, code) {
    super(message);
    this.status = status;
    this.code = code;
  }
}

async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}));
      throw new APIError(errorData.message || 'Request failed', response.status, errorData.code);
    }
    return await response.json();
  } catch (error) {
    if (error instanceof TypeError) {
      throw new APIError('Network error - check your connection', 0, 'NETWORK_ERROR');
    }
    if (error instanceof APIError) throw error;
    throw new APIError('Unexpected error occurred', 500, 'UNKNOWN_ERROR');
  }
}

Component with Error State

Storing errors in component state allows rendering helpful UI with retry options:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [attempt, setAttempt] = useState(0);

  useEffect(() => {
    async function loadUser() {
      try {
        setLoading(true);
        setError(null);
        setUser(await fetchUser(userId));
      } catch (err) {
        setError(err);
        window.Sentry?.captureException(err, { tags: { userId } });
      } finally {
        setLoading(false);
      }
    }
    loadUser();
  }, [userId, attempt]);

  if (loading) return <Spinner />;
  if (error) return <ErrorDisplay error={error} onRetry={() => setAttempt(a => a + 1)} />;
  return <div>{user.name}</div>;
}

Centralized Error Handler

A shared handler ensures consistent error logging and user messaging across the app:

class ErrorHandler {
  constructor(options = {}) {
    this.logger = options.logger || console;
    this.onError = options.onError;
  }

  handle(error, context = {}) {
    this.logger.error('Error:', { message: error.message, ...context });
    window.Sentry?.captureException(error, { contexts: context });
    this.onError?.(error, context);
    return this.getUserMessage(error);
  }

  getUserMessage(error) {
    if (error.code === 'NETWORK_ERROR') return 'Network connection failed';
    if (error instanceof APIError) {
      const messages = {
        404: "The requested resource wasn't found",
        401: 'Please log in to continue',
        403: "You don't have permission to access this",
        500: 'Server error - please try again later'
      };
      return messages[error.status] || error.message || 'Something went wrong';
    }
    return 'An unexpected error occurred';
  }
}

Retry Logic with Exponential Backoff

Retrying transient failures with increasing delays often recovers from temporary issues:

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  let lastError;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) {
        // Don't retry client errors (4xx)
        if (response.status >= 400 && response.status < 500) {
          throw new APIError('Request failed', response.status);
        }
        lastError = new APIError('Request failed', response.status);
      } else {
        return await response.json();
      }
    } catch (error) {
      // Client errors (4xx) and the final attempt aren't retried
      if (error instanceof APIError || attempt === maxRetries - 1) throw error;
      lastError = error;
    }

    // Exponential backoff between attempts: 1s, 2s, ...
    if (attempt < maxRetries - 1) {
      await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
    }
  }
  throw lastError;
}

Error Boundary Integration

Error boundaries only catch errors thrown during rendering, so an async error has to be stored in state and rethrown during render to reach one:

import { ErrorBoundary } from 'react-error-boundary';

function DataComponent() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchData().then(setData).catch(setError);
  }, []);

  // Rethrow during render so the nearest error boundary catches it
  if (error) throw error;

  return <div>{data?.value}</div>;
}

function App() {
  return (
    <ErrorBoundary fallback={<ErrorMessage />}>
      <DataComponent />
    </ErrorBoundary>
  );
}

Global Error Handler

Catching unhandled errors at the window level serves as a safety net for errors that slip through:

window.addEventListener('unhandledrejection', (event) => {
  console.error('Unhandled rejection:', event.reason);
  window.Sentry?.captureException(event.reason);
  showToast('An unexpected error occurred', 'error');
  event.preventDefault();
});

window.addEventListener('error', (event) => {
  console.error('Global error:', event.error);
  window.Sentry?.captureException(event.error);
});

Timeout Handling

AbortController lets you cancel requests that take too long rather than waiting indefinitely:

async function fetchWithTimeout(url, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);

  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeoutId);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (error) {
    clearTimeout(timeoutId);
    if (error.name === 'AbortError') throw new Error('Request timeout');
    throw error;
  }
}

Graceful Degradation

Falling back to cached data when fetches fail lets users keep working with stale but useful data:

async function fetchUserWithFallback(userId) {
  try {
    return await fetchUser(userId);
  } catch (error) {
    console.warn('Fetch failed, using cache:', error);
    const cached = getCachedUser(userId);
    return cached ? { ...cached, stale: true } : { id: userId, name: 'User', stale: true };
  }
}

Benefits

  • Users see what went wrong instead of staring at frozen screens or infinite spinners.
  • Transient failures become recoverable. A flaky connection results in a “Try again” button rather than a dead end.
  • Production issues are much easier to debug when errors are logged with full context about user actions and request parameters.
  • Even when the main API is down, cached data or a simplified experience keeps the app partly usable.
  • Different error types get appropriate treatment: auth failures redirect to login, network errors offer retry, validation errors highlight specific fields.
  • Users don’t lose work, because good error handling keeps form state and lets people retry without re-entering everything.

Tradeoffs

  • Try-catch blocks add boilerplate, and the main logic can get buried under error handling code.
  • Empty catch blocks like catch (e) {} silence errors in the moment, but make production problems much harder to debug.
  • Every error type needs a decision: retry or fail, modal or inline, redirect or display. Those decisions take time.
  • Centralized handlers can oversimplify; not every 500 error should trigger the same generic toast when some need specific responses.
  • In React, async error handling is awkward: an error thrown from an async callback in useEffect doesn’t reach error boundaries, so you have to catch errors, store them in state, and either render the error UI yourself or rethrow during render.
  • Error messages serve two audiences: friendly for users, detailed for developers. This often means two versions of every message.
  • Aggressive logging floods monitoring tools with noise; not every transient failure needs to wake someone up at 3am.
  • Cached data fallbacks can confuse users who don’t realize they’re seeing stale information. Staleness needs to be visually obvious.

Summary

Error handling turns failures that would crash the app into situations users can recover from, by catching exceptions, validating data, and giving users clear feedback. That means handling errors at the right boundaries, telling apart the errors users can fix from the ones that need technical intervention, and always offering a way forward.

See also

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.