JavaScript Core/
Lesson

You know how to write async JavaScript that fetches data and chains operations. But what happens when the network drops, the server returns a 500, or the response shape is wrong? Without proper error handling, these failures crash your application or leave users staring at infinite loading spinners. Robust error handling is what separates a working demo from production-ready code.

The try/catch/finally structure

With async/awaitWhat is async/await?A syntax that lets you write asynchronous code (like fetching data) in a readable, step-by-step style instead of chaining callbacks., error handling uses the familiar try/catch syntax:

async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    return await response.json();
  } catch (error) {
    console.error('Failed to fetch user:', error.message);
    return null;
  } finally {
    console.log('Fetch attempt completed');
  }
}
AI pitfall
AI tools wrap everything in a generic try/catch with catch (e) { console.log(e) } but never handle specific error types. A 401 (unauthorized) needs a redirect to login. A 422 (validation) needs to show field errors. A 500 (server error) needs a retry. Catching everything the same way means your app responds identically to completely different problems. Always check the error type and handle each case appropriately.

What gets caught

Error sourceExampleCaught by catch?
Network failureNo internet, DNS errorYes
Code error in try blockReferenceError, TypeErrorYes
Thrown errorthrow new Error(...)Yes
Rejected awaited Promiseawait failingPromise()Yes
HTTP 404/500 from fetchresponse.status === 404No, must check manually
02

The fetch error handling trap

This is the biggest gotcha: fetch does not reject on HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. error status codes. A 404 response is a "successful" fetch.

// This will NOT catch a 404!
try {
  const response = await fetch('/api/non-existent');
  const data = await response.json(); // Might parse error HTML as JSON
} catch (error) {
  // Only runs on network failure, not HTTP errors
}

Always check response.ok:

async function safeFetch(url) {
  try {
    const response = await fetch(url);

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    return await response.json();
  } catch (error) {
    console.error('Fetch failed:', error.message);
    return null;
  }
}
03

Custom error classes

For complex applications, generic Error objects are not enough. Create custom classes to distinguish between error types:

class NetworkError extends Error {
  constructor(message) {
    super(message);
    this.name = 'NetworkError';
  }
}

class AuthenticationError extends Error {
  constructor(message) {
    super(message);
    this.name = 'AuthenticationError';
  }
}

class ValidationError extends Error {
  constructor(message, fields) {
    super(message);
    this.name = 'ValidationError';
    this.fields = fields;
  }
}

Use them in an APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. client to throw specific errors:

async function apiClient(url, options = {}) {
  try {
    const response = await fetch(url, options);

    if (response.status === 401) {
      throw new AuthenticationError('Please log in again');
    }
    if (response.status === 422) {
      const errors = await response.json();
      throw new ValidationError('Validation failed', errors);
    }
    if (!response.ok) {
      throw new NetworkError(`Server error: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    if (error instanceof AuthenticationError ||
        error instanceof ValidationError ||
        error instanceof NetworkError) {
      throw error;
    }
    throw new NetworkError('Request failed: ' + error.message);
  }
}

Then handle each type differently:

try {
  const user = await apiClient('/api/user');
  displayUser(user);
} catch (error) {
  if (error instanceof AuthenticationError) {
    redirectToLogin();
  } else if (error instanceof ValidationError) {
    showFieldErrors(error.fields);
  } else {
    showGenericError(error.message);
  }
}
04

Error handling patterns

Retry with exponential backoffWhat is exponential backoff?A retry strategy where each attempt waits twice as long as the previous one, giving an overloaded server progressively more time to recover.:

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

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return await response.json();
    } catch (error) {
      lastError = error;
      if (error.message.includes('HTTP 4')) throw error; // Don't retry client errors
      if (attempt < maxRetries - 1) {
        await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
      }
    }
  }

  throw lastError;
}

Graceful degradation with cache fallback:

async function loadData() {
  try {
    const data = await fetchData();
    saveToCache(data);
    return data;
  } catch (error) {
    console.warn('Using cached data:', error.message);
    const cached = getFromCache();
    if (cached) return { ...cached, stale: true };
    throw new Error('No data available offline');
  }
}

05

Common error handling mistakes

Empty catch blocks, errors vanish:

// Bad - error disappears silently
try { await fetchData(); } catch (e) { }

// Good - at least log the error
try { await fetchData(); } catch (error) {
  console.error('fetchData failed:', error);
}

Catching too broadly:

// Bad - hides programming errors
try { await riskyOperation(); } catch (e) { return null; }

// Good - only handle expected errors
try {
  await riskyOperation();
} catch (error) {
  if (error instanceof NetworkError) return null;
  throw error; // Re-throw unexpected errors
}

Forgetting async functions return Promises:

// Bad - unhandled rejection
fetchUserData(123);

// Good - handle the returned Promise
fetchUserData(123).catch(console.error);
// or
await fetchUserData(123);

06

The finally block

finally executes whether the try succeeds or fails. Use it for cleanup that must always happen:

async function fetchWithLoadingState() {
  showSpinner();
  disableButton();

  try {
    const data = await fetchData();
    displayData(data);
  } catch (error) {
    showError(error.message);
  } finally {
    hideSpinner();      // Always runs
    enableButton();     // Always runs
  }
}
07

Quick reference

PatternWhen to use
try/catch around awaitEvery async operation that might fail
if (!response.ok)Every fetch call, HTTP errors are not thrown automatically
Custom error classesWhen different errors need different handling
finally blockCleanup (spinners, button states, connections)
Re-throw unknown errorsWhen you only handle specific error types
.catch() on Promise chainsWhen not using async/await
Retry with backoffNetwork requests that might fail transiently
javascript
async function safeFetch(url) {
  try {
    const res = await fetch(url);
    if (!res.ok) {
      throw new Error(`HTTP ${res.status}`);
    }
    return await res.json();
  } catch (error) {
    console.error('Fetch failed:', error.message);
    return null;
  } finally {
    console.log('Request completed');
  }
}