JavaScript Error Handling Patterns That Actually Work in Production

deep-diveJune 01, 2026· 7 min read

I once spent three hours debugging why a feature silently broke in production. The API call failed, the error got swallowed by a .then() chain, and the UI just... showed nothing. No error state, no toast, no log. Just a blank section where data should've been.

That's when I stopped treating error handling as an afterthought and started treating it as architecture.

Most JavaScript error handling tutorials show you try/catch and call it a day. That's like teaching someone "use a hammer" and calling them a carpenter. Let me show you the patterns I actually use in production.

The Problem With Most Error Handling

Here's the code I see in most codebases:

async function loadUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    const user = await response.json();
    return user;
  } catch (error) {
    console.error(error);
  }
}

Three problems with this:

  1. The caller has no idea something failed. The function returns undefined on error — which means every caller now needs to check for undefined AND handle the error state.
  2. console.error in production is useless. Nobody's watching the console on your user's laptop.
  3. It catches too much. A typo in response.json() gets caught the same as a network failure.

Let me show you better patterns.

Pattern 1: Typed Error Classes

Stop throwing generic Error objects. Create error types that let your callers handle different failures differently:

class AppError extends Error {
  constructor(message, { code, statusCode, context = {} } = {}) {
    super(message);
    this.name = this.constructor.name;
    this.code = code;
    this.statusCode = statusCode;
    this.context = context;
    this.timestamp = new Date().toISOString();
  }
}

class NetworkError extends AppError {
  constructor(message, context = {}) {
    super(message, {
      code: 'NETWORK_ERROR',
      statusCode: 0,
      context,
    });
  }
}

class ApiError extends AppError {
  constructor(message, { statusCode, body } = {}) {
    super(message, {
      code: 'API_ERROR',
      statusCode,
      context: { body },
    });
  }
}

class ValidationError extends AppError {
  constructor(message, fields = {}) {
    super(message, {
      code: 'VALIDATION_ERROR',
      statusCode: 400,
      context: { fields },
    });
  }
}

Now your error handling becomes declarative:

try {
  const user = await loadUser(userId);
} catch (error) {
  if (error instanceof NetworkError) {
    showToast('Check your internet connection');
  } else if (error instanceof ApiError && error.statusCode === 404) {
    router.push('/not-found');
  } else if (error instanceof ApiError && error.statusCode === 401) {
    router.push('/login');
  } else {
    // Unknown error — report to error tracking
    captureException(error);
    showToast('Something went wrong');
  }
}

Each error type maps to a specific user action. No guessing.

Pattern 2: The Safe Fetch Wrapper

Your API layer should be the most defensive code in your app. Here's the pattern I use everywhere:

async function safeFetch(url, options = {}) {
  let response;

  try {
    response = await fetch(url, {
      ...options,
      signal: options.signal ?? AbortSignal.timeout(10_000),
    });
  } catch (error) {
    // Network-level failure (DNS, timeout, CORS, offline)
    if (error.name === 'AbortError' || error.name === 'TimeoutError') {
      throw new NetworkError(`Request to ${url} timed out`, { url });
    }
    throw new NetworkError(`Network failure: ${error.message}`, { url });
  }

  // HTTP error responses
  if (!response.ok) {
    let body;
    try {
      body = await response.json();
    } catch {
      body = await response.text().catch(() => null);
    }

    throw new ApiError(
      body?.message ?? `HTTP ${response.status}`,
      { statusCode: response.status, body }
    );
  }

  // Parse response
  try {
    return await response.json();
  } catch (error) {
    throw new ApiError('Invalid JSON response', {
      statusCode: response.status,
      body: null,
    });
  }
}

Notice how each failure mode becomes a specific error type. The caller always knows what went wrong, not just that something went wrong.

The AbortSignal.timeout(10_000) is important — without it, a hung server means your UI hangs forever.

Pattern 3: Async Error Boundaries

In React, error boundaries only catch sync errors in lifecycle methods. They don't catch errors in event handlers or async code. Here's how I handle that:

function useAsyncAction() {
  const [state, setState] = React.useState({
    data: null,
    error: null,
    loading: false,
  });

  const execute = React.useCallback(async (asyncFn) => {
    setState((prev) => ({ ...prev, loading: true, error: null }));

    try {
      const data = await asyncFn();
      setState({ data, error: null, loading: false });
      return data;
    } catch (error) {
      setState((prev) => ({ ...prev, error, loading: false }));

      // Still throw so callers can react if they want
      throw error;
    }
  }, []);

  const reset = React.useCallback(() => {
    setState({ data: null, error: null, loading: false });
  }, []);

  return { ...state, execute, reset };
}

Usage in a component:

function UserProfile({ userId }) {
  const { data: user, error, loading, execute } = useAsyncAction();

  React.useEffect(() => {
    execute(() => loadUser(userId));
  }, [userId, execute]);

  if (loading) return <Skeleton />;
  if (error instanceof NetworkError) return <OfflineBanner />;
  if (error instanceof ApiError && error.statusCode === 404) {
    return <UserNotFound />;
  }
  if (error) return <GenericError error={error} />;

  return <UserCard user={user} />;
}

Every error state gets its own UI. No generic "something went wrong" banners for preventable errors.

Pattern 4: Retry With Exponential Backoff

Not all errors are permanent. Network failures on mobile are transient. A retry with backoff handles this gracefully:

async function withRetry(fn, {
  maxAttempts = 3,
  baseDelay = 1000,
  shouldRetry = () => true,
} = {}) {
  let lastError;

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;

      // Don't retry client errors (4xx) — the server won't change its mind
      if (error instanceof ApiError && error.statusCode >= 400 && error.statusCode < 500) {
        throw error;
      }

      // Let caller decide
      if (!shouldRetry(error, attempt)) {
        throw error;
      }

      // Don't sleep on the last attempt
      if (attempt < maxAttempts - 1) {
        const delay = baseDelay * Math.pow(2, attempt);
        // Add jitter to avoid thundering herd
        const jitter = Math.random() * baseDelay;
        await new Promise((resolve) => setTimeout(resolve, delay + jitter));
      }
    }
  }

  throw lastError;
}

Real-world usage:

const data = await withRetry(
  () => safeFetch('/api/dashboard'),
  { maxAttempts: 3, baseDelay: 500 }
);

Key details:

  • 4xx errors are not retried — the server rejected the request, retrying won't help
  • Jitter prevents all clients from retrying simultaneously after a server blip
  • shouldRetry lets callers opt out of retry for specific errors

Pattern 5: AggregateError for Concurrent Failures

Promise.allSettled is great, but it doesn't tell you what failed in a useful way. Here's a pattern using AggregateError:

async function fetchAll(resources) {
  const results = await Promise.allSettled(
    resources.map(({ url, label }) =>
      safeFetch(url).catch((error) => {
        // Attach context so we know WHICH resource failed
        error.context = { ...error.context, label };
        throw error;
      })
    )
  );

  const fulfilled = [];
  const errors = [];

  for (const result of results) {
    if (result.status === 'fulfilled') {
      fulfilled.push(result.value);
    } else {
      errors.push(result.reason);
    }
  }

  if (errors.length > 0 && fulfilled.length === 0) {
    // Everything failed — throw aggregate error
    throw new AggregateError(
      errors,
      `All ${errors.length} requests failed`
    );
  }

  // Partial success — caller gets what worked + info about failures
  return { data: fulfilled, partialErrors: errors };
}

Usage:

const { data, partialErrors } = await fetchAll([
  { url: '/api/user', label: 'user' },
  { url: '/api/settings', label: 'settings' },
  { url: '/api/notifications', label: 'notifications' },
]);

// Show user + settings even if notifications failed
// Log partial failures for monitoring
partialErrors.forEach((error) => {
  console.warn(`Failed to load ${error.context.label}:`, error.message);
});

This is the pattern that prevents a single slow API from breaking your entire page.

Pattern 6: Global Error Safety Net

No matter how good your error handling is, something will slip through. Set up global handlers:

// Catch uncaught sync errors
window.addEventListener('error', (event) => {
  // Ignore cross-origin script errors (they give no useful info)
  if (event.message === 'Script error.') return;

  captureException(event.error, {
    tags: { source: 'uncaught_error' },
    extra: {
      filename: event.filename,
      lineno: event.lineno,
      colno: event.colno,
    },
  });
});

// Catch unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
  const error = event.reason;

  // Prevent default console warning (we're handling it ourselves)
  event.preventDefault();

  captureException(error, {
    tags: { source: 'unhandled_rejection' },
  });
});

Important: event.preventDefault() on unhandledrejection stops the console warning but also means you are responsible for making sure the error gets reported somewhere. Pair this with Sentry, LogRocket, or your own error tracking.

The Error Handling Decision Framework

When you're about to write error handling code, ask yourself:

QuestionPattern
Is this a network call?safeFetch wrapper
Can this fail temporarily?withRetry
Multiple things happening at once?fetchAll with partial success
Showing a UI for the error?useAsyncAction hook
Need to distinguish error types?Custom error classes
Is this a case I didn't anticipate?Global safety net

Common Mistakes I've Made (So You Don't Have To)

Swallowing errors silently:

// ❌ Returns undefined on error — caller can't tell failure from null data
async function getUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    return await res.json();
  } catch {}
}

If you catch an error, either handle it completely or re-throw it. Never do nothing.

Catching too broadly:

// ❌ Catches TypeError from a typo, not just API failures
try {
  const user = await fetchUser(id);
  cosnt name = user.name; // typo — this error gets swallowed too
} catch (error) {
  showGenericError();
}

Keep your try blocks small. Wrap only the code that can actually throw.

Using .catch() as a substitute for proper handling:

// ❌ Error goes to /dev/null
fetch('/api/track', { method: 'POST', body: data }).catch(() => {});

For fire-and-forget requests, at least log failures in development:

fetch('/api/track', { method: 'POST', body: data }).catch((error) => {
  if (process.env.NODE_ENV === 'development') {
    console.error('Analytics failed:', error);
  }
});

Quick Reference

  • Always throw typed errorsnew NetworkError() not new Error('something broke')
  • Wrap only code that can fail in try/catch — keep blocks small
  • Retry network failures with backoff and jitter, but never retry 4xx errors
  • Use Promise.allSettled for concurrent requests — partial success is better than total failure
  • Set up global handlers as a safety net, not as your primary strategy
  • Every error gets a user-visible response — even if it's just "try again"

Good error handling isn't about preventing errors. It's about making sure that when things break — and they will — your users aren't the ones who discover it first.