Why Your fetch() Error Handling Is Silently Swallowing Bad Responses

June 07, 2026 6 min read 44 views
A stylized browser window displaying a red HTTP error status code against a dark blue gradient, representing a silent fetch API failure in JavaScript.

You call fetch(), the promise resolves, and you assume the request succeeded. Meanwhile, the server returned a 404 or a 500, and your app just silently processed an error page as if it were real data. This is one of the most common bugs in JavaScript frontends, and it almost never surfaces in testing.

The root cause is a design decision in the Fetch API that surprises almost every developer the first time they hit it: fetch() only rejects its promise on network failures, not on HTTP error status codes.

What You'll Learn

  • Why fetch() doesn't throw on 4xx and 5xx responses
  • How to check response.ok and response.status correctly
  • How to build a reusable wrapper that throws on bad responses
  • Common pitfalls around JSON parsing errors mixed with HTTP errors
  • How to handle errors cleanly with both .then() chains and async/await

The Fetch Promise Model

When you call fetch(url), you get back a promise that resolves to a Response object. That object represents the HTTP response β€” including the status code, headers, and body stream. The promise rejects only if the request never completes at all: DNS lookup failure, no network connection, CORS preflight blocked, or a timeout you've manually enforced.

An HTTP 404 Not Found is a perfectly valid, complete HTTP response. The server received your request and replied with a status code. From the Fetch API's perspective, that's a success. The promise resolves.

// This looks fine. It is not fine.
fetch('/api/users/999')
  .then(response => response.json())
  .then(data => {
    console.log(data.name); // Could be an error body, or blow up entirely
  })
  .catch(err => {
    console.error('Request failed:', err); // Never runs on a 404
  });

If the server returns { "error": "User not found" } with a 404 status, the .then() chain runs happily, and data.name is undefined. If the server returns an HTML error page, response.json() throws a parse error and that error gets caught β€” which looks like a network error but is actually a status code problem you never checked.

The response.ok Property

Every Response object has an ok property. It's true when the status code is in the 200–299 range, and false for everything else. This is your first line of defense.

fetch('/api/users/999')
  .then(response => {
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    return response.json();
  })
  .then(data => {
    console.log(data.name);
  })
  .catch(err => {
    console.error('Request failed:', err); // Now catches HTTP errors too
  });

By throwing inside the .then() handler, you convert an HTTP error into a rejected promise. The .catch() block at the end handles both network failures and bad status codes in one place.

Doing It Right With async/await

Most modern code uses async/await instead of chained .then() calls. The same principle applies β€” you need to explicitly check response.ok after awaiting the fetch.

async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);

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

  const data = await response.json();
  return data;
}

// Calling it
try {
  const user = await getUser(999);
  console.log(user.name);
} catch (err) {
  console.error('Failed to load user:', err.message);
}

One thing to note: await response.json() can still throw if the response body isn't valid JSON β€” even when response.ok is true. A server might return a 200 with an empty body, or HTML instead of JSON if a proxy intercepts the request. Keep both error sources in mind.

Building a Reusable Fetch Wrapper

Repeating the response.ok check everywhere is tedious and easy to forget. A small wrapper function centralizes the logic and gives you a consistent interface across your entire app.

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

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

  if (!response.ok) {
    let errorMessage = `HTTP ${response.status}`;

    // Try to read an error message from the response body
    try {
      const errorBody = await response.json();
      errorMessage = errorBody.message || errorBody.error || errorMessage;
    } catch {
      // Body wasn't JSON β€” use the status text instead
      errorMessage = response.statusText || errorMessage;
    }

    throw new HttpError(response.status, errorMessage);
  }

  // Handle 204 No Content β€” no body to parse
  if (response.status === 204) {
    return null;
  }

  return response.json();
}

Now every call to apiFetch either returns parsed JSON or throws an HttpError. You can check err.status in your catch blocks to handle 401s, 403s, and 404s differently without duplicating that logic everywhere.

try {
  const user = await apiFetch('/api/users/999');
  renderUser(user);
} catch (err) {
  if (err instanceof HttpError && err.status === 404) {
    showNotFound();
  } else if (err instanceof HttpError && err.status === 401) {
    redirectToLogin();
  } else {
    showGenericError(err.message);
  }
}

The JSON Parsing Trap

Even after you've nailed the response.ok check, there's another silent failure mode: calling response.json() on a response that isn't actually JSON.

This happens more often than you'd expect. A reverse proxy returns an HTML 502 page. A CDN intercepts the request and returns a plain-text response. A server-side framework crashes and dumps a stack trace as text/html. In all these cases, response.ok might be false (which you now check), but if you try to parse the body as JSON for the error message, that secondary json() call throws and masks the original problem.

The nested try/catch inside apiFetch above handles this correctly β€” it falls back to the status text if the error body can't be parsed. Always defend the error-parsing path separately from the happy path.

Handling Timeouts

fetch() has no built-in timeout. A request to a slow or hung server will wait indefinitely by default, which eventually looks like a silent failure in your UI. Use AbortController to set a deadline.

async function apiFetchWithTimeout(url, options = {}, timeoutMs = 8000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal,
    });

    if (!response.ok) {
      throw new HttpError(response.status, `HTTP ${response.status}`);
    }

    return response.status === 204 ? null : await response.json();
  } catch (err) {
    if (err.name === 'AbortError') {
      throw new Error(`Request timed out after ${timeoutMs}ms`);
    }
    throw err;
  } finally {
    clearTimeout(timeoutId);
  }
}

The finally block clears the timeout regardless of outcome, so you don't leave a dangling timer after a fast response.

Common Pitfalls

Reading the body twice

A Response body is a stream. Once you've called response.json() or response.text(), the stream is consumed. Calling it again throws TypeError: body stream already read. If you need the raw text for logging and also want to parse as JSON, read it as text first, then use JSON.parse().

const text = await response.text();
const data = JSON.parse(text); // Works fine after reading as text

Forgetting CORS preflight failures

A CORS error surfaces as a network-level rejection, not an HTTP status code. response.ok never comes into play because you never get a response object. The error message in the browser console is often vague. Check the Network tab for the actual preflight request to diagnose it.

Treating all 2xx as identical

A 201 Created and a 204 No Content are both in the ok range, but 204 has no body. Calling response.json() on a 204 will throw. Check for no-content responses before parsing, as shown in the wrapper above.

Not checking ok in parallel fetches

When you use Promise.all() to fire multiple requests simultaneously, each individual response still needs its own ok check. Promise.all() rejects fast if any promise rejects, but only if you've actually thrown β€” which means each fetch in the array needs its own check, not just a single guard at the end.

Wrapping Up

The key habit is simple: after every await fetch(), check response.ok before doing anything with the response body. Everything else β€” wrappers, custom error classes, timeout controllers β€” builds on top of that single discipline.

Here are concrete steps to tighten up your error handling today:

  • Audit your existing fetch calls and add if (!response.ok) throw new Error(...) to any that are missing it.
  • Build a single apiFetch wrapper (or adopt a thin library like ky) and route all HTTP calls through it.
  • Add an AbortController timeout to any request that could stall β€” especially in user-facing UI flows.
  • In your error boundaries or global catch blocks, check for err.status so you can give users a meaningful message ("Not found" vs "Server error" vs "Please log in again").
  • Test your error paths deliberately: use a local proxy or mock service to return 404s, 500s, and non-JSON bodies and confirm your UI handles them gracefully.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.