Skip to main content

Why error handling matters

Network requests fail. Servers go down. Users lose internet. APIs return unexpected data. If you don’t handle errors, your app shows a blank screen or crashes silently.
// ❌ No error handling — app crashes silently
async function getUsers() {
  const response = await fetch("/api/users");
  const users = await response.json();
  return users;
}

// ✅ With error handling — app stays usable
async function getUsers() {
  try {
    const response = await fetch("/api/users");
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (error) {
    console.error("Failed to load users:", error);
    throw error;
  }
}

try/catch/finally

The core pattern for handling errors in async code:
async function loadUserProfile(userId) {
  try {
    // Code that might fail
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const user = await response.json();
    return user;

  } catch (error) {
    // Runs if anything in try block throws
    console.error("Error loading profile:", error.message);
    return null; // Return a fallback value

  } finally {
    // Always runs — success or failure
    console.log("Request finished");
  }
}
try {
  const data = await riskyOperation();
} catch (error) {
  console.error(error.message);
} finally {
  cleanup();
}
Same structure, same behavior. Python catches specific exception types; JavaScript catches everything in one catch block.

Two types of fetch errors

This is where people get confused. fetch can fail in two different ways:

1. Network errors — fetch itself throws

try {
  // These throw immediately — no response at all
  const response = await fetch("/api/users");
} catch (error) {
  // TypeError: Failed to fetch
  // Causes: no internet, DNS failure, CORS blocked, server unreachable
  console.error("Network error:", error.message);
}

2. HTTP errors — fetch succeeds but status is bad

const response = await fetch("/api/users/999");

// fetch succeeded — we got a response
// But the status is 404 (not found)
console.log(response.ok);     // false
console.log(response.status); // 404

// We need to check manually and throw
if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}
fetch only throws on network failures. A 404 or 500 response is not an error from fetch’s perspective — it successfully received a response. Always check response.ok.

Handling both types

async function getUsers() {
  try {
    const response = await fetch("/api/users");

    // Type 2: HTTP error (404, 500, etc.)
    if (!response.ok) {
      const errorBody = await response.json().catch(() => ({}));
      const error = new Error(errorBody.detail || `HTTP ${response.status}`);
      error.status = response.status; // Attach status for easier handling later
      throw error;
    }

    return await response.json();
  } catch (error) {
    // Type 1: Network error (no internet, CORS, etc.)
    // Type 2: HTTP error (thrown above)
    // Both are caught here
    console.error("Request failed:", error.message);
    throw error;
  }
}

User-friendly error messages

Don’t show raw error messages to users. Translate them into something helpful:
function getErrorMessage(error) {
  // Prefer structured data when available
  const status = error.status;

  // Network errors
  // Browser messages vary ("Failed to fetch", "NetworkError", etc.)
  if (!status && error.name === "TypeError") {
    return "Can't connect to the server. Check your internet connection.";
  }

  // HTTP errors
  if (status === 401 || error.message.includes("401")) {
    return "Please log in to continue.";
  }
  if (status === 403 || error.message.includes("403")) {
    return "You don't have permission to do that.";
  }
  if (status === 404 || error.message.includes("404")) {
    return "The item you're looking for doesn't exist.";
  }
  if (status === 500 || error.message.includes("500")) {
    return "Something went wrong on our end. Please try again.";
  }

  // Fallback
  return "Something went wrong. Please try again.";
}

// Usage
try {
  const users = await getUsers();
} catch (error) {
  showNotification(getErrorMessage(error)); // User sees a friendly message
}

In React

function UserList() {
  const [users, setUsers] = useState([]);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function loadUsers() {
      try {
        const response = await fetch("/api/users");
        if (!response.ok) {
          const err = new Error(`HTTP ${response.status}`);
          err.status = response.status;
          throw err;
        }
        const data = await response.json();
        setUsers(data);
      } catch (error) {
        setError(getErrorMessage(error));
      } finally {
        setLoading(false);
      }
    }

    loadUsers();
  }, []);

  if (loading) return <p>Loading...</p>;
  if (error) return <p className="error">{error}</p>;
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

Re-throwing errors

Sometimes you want to handle an error and let the caller handle it too:
async function getUsers() {
  try {
    const response = await fetch("/api/users");
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (error) {
    console.error("API error:", error); // Log it
    throw error; // Re-throw so the caller can handle it too
  }
}

// Caller handles the user-facing part
try {
  const users = await getUsers();
  renderUserList(users);
} catch (error) {
  showErrorBanner(getErrorMessage(error));
}
Re-throw errors when lower-level code shouldn’t decide what the user sees. Log the error for debugging, then throw it up to the component that can show a user-friendly message.

Common mistakes

// ❌ Wrong: catching the error but doing nothing
async function getUsers() {
  try {
    const response = await fetch("/api/users");
    return await response.json();
  } catch (error) {
    // Error is caught but nobody knows about it
    // App shows blank screen — no data, no error message
  }
}

// ✅ Correct: handle or re-throw
async function getUsers() {
  try {
    const response = await fetch("/api/users");
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (error) {
    console.error("Failed:", error);
    throw error; // Let the caller handle it
  }
}
An empty catch block is almost always a bug. If you catch an error, either handle it (show a message, return a fallback) or re-throw it. Silent failures are the hardest bugs to find.
// ❌ Wrong: parsing without checking status
async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json(); // Might parse a 404 error page as JSON
}

// ✅ Correct: check status first
async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new Error(error.detail || `HTTP ${response.status}`);
  }
  return response.json();
}
// ❌ Confusing: mixing Promise .catch() with try/catch
async function getUsers() {
  try {
    const response = await fetch("/api/users").catch(err => {
      throw err; // Unnecessary — try/catch already handles this
    });
    return response.json();
  } catch (error) {
    console.error(error);
  }
}

// ✅ Clean: just use try/catch with async/await
async function getUsers() {
  try {
    const response = await fetch("/api/users");
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (error) {
    console.error(error);
    throw error;
  }
}
When using async/await, stick with try/catch. Don’t mix in .catch() — it adds confusion without any benefit.

What’s next?

Error handling keeps your app stable. Now let’s make it feel fast with loading states — showing users what’s happening while requests are in flight.

Loading states

Show users what’s happening during requests