TL;DR: Error handling is wrapping risky code so that when things go wrong, your app responds gracefully instead of crashing. In JavaScript: try { riskyCode() } catch (err) { handleIt(err) }. For async code: always wrap await calls in try/catch. For React: use Error Boundaries to prevent one bad component from crashing the whole page.

Why AI Coders Need to Know This

AI is optimistic. When it generates a function that fetches data from an API, it typically writes the happy path — the code that works when the network is up, the server responds, and the data is shaped correctly. It often skips the error paths: what happens when the fetch fails, when the server returns a 500, when the user's token has expired, when the data is missing a field your code expects.

In development, this isn't obvious — your network is fast, your server is local, and you're using test data. In production, things fail constantly. A production app without error handling shows users blank screens, infinite spinners, or cryptic error messages. Error handling is what separates an app that feels professional from one that feels fragile.

The good news: error handling patterns are simple once you know them. There are really only four you need to understand for 95% of situations.

Real Scenario

Prompt I Would Type

Build a React component that fetches user data from /api/users/:id
and displays their profile. Make it production-ready with:
- Loading state while data is fetching
- Error state if the fetch fails
- Retry button on error
- Proper error messages for different failure types (network error, 404, 500)

What AI Generated

import { useState, useEffect, useCallback } from 'react';

interface User { id: string; name: string; email: string; bio?: string; }

type FetchState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; message: string };   // discriminated union — status narrows the type

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);

  // fetch() doesn't throw on HTTP errors — you must check response.ok manually
  if (!response.ok) {
    if (response.status === 404) throw new Error('User not found');
    if (response.status === 401) throw new Error('You are not logged in');
    throw new Error(`Server error (${response.status})`);
  }

  return response.json();  // this can also throw if the body isn't valid JSON
}

export function UserProfile({ userId }: { userId: string }) {
  const [state, setState] = useState<FetchState>({ status: 'idle' });

  const loadUser = useCallback(async () => {
    setState({ status: 'loading' });

    try {
      const data = await fetchUser(userId);
      setState({ status: 'success', data });

    } catch (err) {
      // err is type 'unknown' in TypeScript — always narrow it
      const message = err instanceof Error ? err.message : 'Something went wrong';
      setState({ status: 'error', message });
    }
  }, [userId]);

  useEffect(() => {
    loadUser();
  }, [loadUser]);

  // Render based on state
  if (state.status === 'loading') return <div>Loading...</div>;

  if (state.status === 'error') return (
    <div>
      <p>Error: {state.message}</p>
      <button onClick={loadUser}>Try again</button>
    </div>
  );

  if (state.status === 'success') return (
    <div>
      <h2>{state.data.name}</h2>
      <p>{state.data.email}</p>
    </div>
  );

  return null;  // idle state — before first load
}

Understanding Each Part

try / catch / finally

try {
  // Code that might throw an error
  const result = riskyOperation();
  console.log(result);

} catch (err) {
  // Runs only if an error was thrown in the try block
  // err is the Error object (has .message, .name, .stack properties)
  console.error('Something went wrong:', err.message);

} finally {
  // Always runs, regardless of whether an error occurred
  // Use for cleanup: closing connections, hiding loading spinners, etc.
  setLoading(false);
}

Async Error Handling — The Missing try/catch

This is the most common error handling mistake in AI-generated code. Without try/catch, a rejected promise becomes an unhandled promise rejection — in the browser it silently fails, in Node.js it can crash the process:

// ❌ No error handling — silent failure
async function loadData() {
  const data = await fetch('/api/data').then(r => r.json());
  setData(data);
}

// ✅ With error handling
async function loadData() {
  try {
    const response = await fetch('/api/data');
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const data = await response.json();
    setData(data);
  } catch (err) {
    setError(err instanceof Error ? err.message : 'Failed to load data');
  }
}

Custom Error Classes

// Create meaningful error types for different failure modes
class AuthError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'AuthError';
  }
}

class NetworkError extends Error {
  constructor(message: string, public statusCode: number) {
    super(message);
    this.name = 'NetworkError';
  }
}

// Then handle them specifically in catch
try {
  await protectedOperation();
} catch (err) {
  if (err instanceof AuthError) {
    router.push('/login');         // redirect to login
  } else if (err instanceof NetworkError && err.statusCode === 429) {
    await sleep(1000);             // rate limited — wait and retry
    await protectedOperation();
  } else {
    throw err;                     // re-throw unexpected errors
  }
}

React Error Boundaries

try/catch doesn't work for React rendering errors — if a component throws during render, it propagates up and crashes the whole tree. Error Boundaries are the React solution:

// Using react-error-boundary library (recommended)
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div>
      <p>Something went wrong: {error.message}</p>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

// Wrap components that might throw
<ErrorBoundary FallbackComponent={ErrorFallback}>
  <UserProfile userId={id} />
</ErrorBoundary>

API Route Error Handling (Next.js)

// app/api/users/[id]/route.ts
export async function GET(request: Request, { params }: { params: { id: string } }) {
  try {
    const user = await db.user.findUnique({ where: { id: params.id } });

    if (!user) {
      return Response.json({ error: 'User not found' }, { status: 404 });
    }

    return Response.json(user);

  } catch (err) {
    // Log the full error server-side (never send stack traces to clients)
    console.error('[GET /api/users/:id]', err);

    // Return a generic message to the client
    return Response.json({ error: 'Internal server error' }, { status: 500 });
  }
}

What AI Gets Wrong About Error Handling

1. Missing try/catch on Async Operations

The most common issue. AI generates const data = await fetchSomething() without any surrounding error handling. Every await that makes a network request, reads a file, or queries a database should be inside a try/catch.

2. Not Checking response.ok

fetch() only throws for network failures (no internet, DNS failure). An HTTP 404 or 500 response does NOT throw — response.ok is false, but your code continues. Always check if (!response.ok) throw new Error(...) after every fetch call.

3. Swallowing Errors with Empty Catch Blocks

AI sometimes generates catch (err) {} — an empty catch block that ignores the error entirely. This is worse than no error handling because it actively hides problems. At minimum, console.error(err) and set an error state.

4. Exposing Internal Errors to Users

Sending err.message or err.stack directly to API clients exposes internal implementation details (file paths, database names, library versions) that help attackers. Always log internally and return a generic message to the client.

How to Debug Error Handling with AI

When an error is being swallowed silently, ask Cursor or Claude: "Audit this codebase for async functions that are missing try/catch blocks. List every await call that isn't wrapped in error handling." This is one of the most valuable prompts for production-readiness reviews.

For specific errors: paste the full stack trace (not just the message) into the AI. The stack trace shows exactly where the error originated, which is usually more useful than the error message itself.

What to Learn Next

Frequently Asked Questions

Error handling is the practice of anticipating things that can go wrong in your code and writing logic to deal with them gracefully — showing a useful message, retrying an operation, or logging the issue — instead of letting the program crash or silently fail.

try/catch is the fundamental error handling construct in JavaScript. Code inside the try block runs normally. If any of it throws an error, execution immediately jumps to the catch block, where you have access to the error object and can handle it. A finally block runs regardless of whether an error occurred.

Wrap await calls in try/catch: try { const data = await fetchData() } catch (err) { console.error(err) }. Without try/catch around await, an unhandled promise rejection crashes your Node.js server or causes silent failures in the browser. Every async function that makes network requests or database calls should have error handling.

A React Error Boundary is a component that catches JavaScript errors in its child component tree and displays a fallback UI instead of crashing the whole page. It's implemented with the componentDidCatch lifecycle method (class components) or the react-error-boundary library. Without one, a single component error crashes the entire React app.

In JavaScript, the terms are often used interchangeably. Technically, an Error is an object (TypeError, RangeError, SyntaxError, etc.) and throwing one creates an 'exception' — an interruption of normal flow. The catch block handles the exception and gives you access to the Error object via its parameter.