Skip to main content
Saved

Pattern Resilience Advanced

Error Handling

Your app should catch errors where it calls your APIs, and show users a clear message that tells them what they can do next, such as trying again.

Examples
React, Vue and native web
Read
4 min

The problem

An error message is bad, but a loading spinner that never stops is worse, and that’s what users get when the error is never caught. They might wait and refresh, without ever finding out that the request failed five seconds ago.

The pattern

My rule is that every async operation gets explicit error handling. I wrap it in try-catch and store the error in state, so users get a useful message that explains what happened and what they can do about it.

Problem

I’ve debugged many production issues where the console showed a clear error but users saw nothing helpful. Network timeouts and malformed JSON both ended up as silent failures or as the dreaded “something went wrong” message that doesn’t help anyone.

The inconsistency drives me crazy. One component retries automatically, while another shows an error with no retry button. Plenty of errors aren’t even logged, because someone swallowed them with an empty catch block.

Solution

Different errors also need different responses. A 404 tells you the resource doesn’t exist, so retrying won’t help and you should show a “not found” message. A network timeout suggests a temporary problem, so a retry button makes sense. A 401 tells you the user needs to log in, so it’s better to redirect them than to show a generic error. A 422 tells you the input failed validation, so you can highlight the fields that need fixing.

You should also log each error with enough context to debug it later, such as the user ID and the request parameters. The message users see should tell them what to do, like “Check your connection and try again,” instead of cryptic developer output like “Error: undefined is not a function.”

Where you can, it’s better for the app to keep partly working 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

These examples show how I handle errors at different levels, from a basic try-catch up to custom error types, retry logic, and falling back to cached data.

Basic Error Handling

The basic version wraps the async operation in try-catch, logs the error, and throws it again so the calling code can decide how to handle it.

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 the types of error 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

When the error is stored in component state, the component can render a helpful message with a retry button.

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

With one shared handler, errors get logged the same way across the whole app, and users get consistent messages.

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

A request that fails because of a temporary problem often succeeds on a later attempt. This version retries those failures with a longer delay before each attempt, and it doesn’t retry client errors.

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 before a boundary can catch it.

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

Listeners on the window act as a safety net, catching any unhandled errors that the rest of your code missed.

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

With AbortController, you can cancel a request that takes too long, instead of waiting for it forever.

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

When the fetch fails, this function falls back to cached data, so users can keep working with data that’s out of date but still useful.

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 a page that has stopped responding or a spinner that never ends.
  • Users can recover from temporary failures, because a flaky connection gives them a “Try again” button instead of 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.
  • Each type of error can get the right response, so an auth failure redirects to the login page and a validation error highlights the fields that need fixing.
  • Users don’t have to lose their work, because good error handling keeps form state and lets people retry without re-entering everything.

Tradeoffs

  • Try-catch blocks add boilerplate, and your main logic can get buried under error handling code.
  • Empty catch blocks like catch (e) {} hide errors at the time, but they make production problems much harder to debug.
  • You have to decide what to do with each type of error, such as whether to retry or fail and whether to show a modal or an inline message, and those decisions take time.
  • A centralized handler can oversimplify things, because some 500 errors need a specific response instead of the same generic toast.
  • Async error handling is awkward in React, because an error thrown from an async callback in useEffect never gets to an error boundary, so you have to catch errors, store them in state, and either render the error UI yourself or rethrow during render.
  • Error messages are for two different audiences, so you often end up writing a friendly version for users and a detailed version for developers.
  • Logging too much fills your monitoring tools with noise, and a temporary failure usually shouldn’t wake someone up at 3am.
  • Falling back to cached data can confuse users who don’t realize the information is out of date, so the UI needs to make that obvious.

Summary

Good error handling catches exceptions and gives users clear feedback, so a failure that would have broken the app becomes something they can recover from. You tell apart the errors users can fix from the ones a developer has to fix, and you always give users a next step.

See also

Next in Resilience Loading State A loading state shows users that an asynchronous operation is still in progress, so they know the app is working.

Newsletter

A monthly email
from Den Odell.

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

No spam. Unsubscribe anytime.