Why fetch() Does Not Throw an Error for 404 or 500

Why fetch() Does Not Throw an Error for 404 or 500

For developers coming from XMLHttpRequest libraries like Axios, jQuery $.ajax, or Python’s requests, one of the most counter-intuitive behaviors in modern JavaScript is how the native fetch() API handles HTTP error responses. When a server responds with an HTTP 404 Not Found, 401 Unauthorized, or 500 Internal Server Error status code, a standard try...catch block around your fetch() call does not catch anything.

The Promise resolves normally, and your execution continues down the happy path unless you explicitly inspect the response. In this comprehensive technical guide, we will analyze the WHATWG Fetch specification, understand why fetch() was architected this way, examine the critical role of response.ok, and build a reusable wrapper to handle HTTP errors cleanly.

The WHATWG Specification: What Rejection Actually Means

WHATWG Fetch standard network error rejection vs HTTP status codes flow
WHATWG Fetch specification: rejection criteria limited to network-level failures

To grasp why fetch() behaves this way, we must consult the official WHATWG Fetch Living Standard and the MDN Web Docs on the Fetch API.

According to the standard, the Promise returned by fetch() only rejects upon encountering a network failure or when anything prevented the request from completing. Examples of conditions that cause a Promise rejection include:

  • The client device has lost internet connectivity (offline state).
  • A DNS lookup failure prevents resolving the domain name.
  • A Cross-Origin Resource Sharing (CORS) header violation blocks the browser from accessing the response.
  • The server forcibly terminates the TCP connection before sending headers.
  • A TLS/SSL certificate handshake fails.

From the browser’s perspective, an HTTP 404 or 500 status code represents a successful network transaction. The browser successfully established a connection, sent the HTTP request, and received a valid HTTP response with status headers from the server. Because the protocol communication succeeded, the Promise fulfills with a Response object.

The Common Bug: Silent Failures in try...catch

Consider the following typical pattern written by developers assuming Axios-like behavior:

async function loadUserProfile(userId) {
    try {
        // Requesting a non-existent user returns HTTP 404
        const response = await fetch(`https://api.example.com/users/${userId}`);
        
        // BUG: This line STILL EXECUTES because fetch did not reject!
        const data = await response.json();
        console.log("User data received:", data);
        renderProfile(data);
    } catch (error) {
        // This catch block NEVER FIRES for 404 or 500!
        console.error("Network error encountered:", error);
        showErrorMessage("Could not load profile.");
    }
}

When the endpoint returns a 404, the application attempts to parse response.json(). If the server returned an HTML error page (e.g. Apache or Nginx 404 page), parsing fails with a secondary SyntaxError: Unexpected token < in JSON at position 0, masking the real root cause! (See our companion tutorial on why JSON.parse() says unexpected token < in JavaScript).

The Solution: Inspecting response.ok and response.status

The native Response interface provides several properties specifically designed for status checking:

  • response.ok: A boolean flag that is true if the HTTP status code is in the 200–299 range (Success), and false otherwise.
  • response.status: The numerical HTTP status code (e.g., 200, 404, 500).
  • response.statusText: The status message sent by the server (e.g., "Not Found", "Internal Server Error").

The Idiomatic Native Fix:

async function loadUserProfile(userId) {
    try {
        const response = await fetch(`https://api.example.com/users/${userId}`);

        // Manually guard against non-2xx status codes
        if (!response.ok) {
            throw new Error(`HTTP error! Status: ${response.status} - ${response.statusText}`);
        }

        const data = await response.json();
        renderProfile(data);
    } catch (error) {
        // Now catches both network dropouts AND 4xx/5xx HTTP errors
        console.error("Request failed:", error.message);
        showErrorMessage(error.message);
    }
}

Building a Robust, Production-Grade customFetch Wrapper

Production ready custom fetch wrapper with automatic HttpError throwing
Reusable customFetch client wrapping response.ok verification and JSON parsing

Rather than duplicating if (!response.ok) checks across dozens of API service modules, professional JavaScript architectures wrap fetch() into a unified HTTP client utility:

/**
 * Custom fetch client that rejects on HTTP error statuses and extracts JSON details.
 */
class HttpError extends Error {
    constructor(response, data) {
        super(`HTTP ${response.status}: ${response.statusText}`);
        this.name = "HttpError";
        this.status = response.status;
        this.response = response;
        this.data = data;
    }
}

async function apiClient(url, options = {}) {
    const defaultHeaders = {
        "Content-Type": "application/json",
        "Accept": "application/json"
    };

    const config = {
        ...options,
        headers: {
            ...defaultHeaders,
            ...options.headers
        }
    };

    const response = await fetch(url, config);

    // If the server responded with an error status
    if (!response.ok) {
        let errorBody = null;
        try {
            // Attempt to parse structured error message from API backend
            errorBody = await response.json();
        } catch (e) {
            // Fallback to text if API returned plain HTML error
            errorBody = await response.text();
        }
        throw new HttpError(response, errorBody);
    }

    // Return parsed JSON for 204 No Content safely
    if (response.status === 204) {
        return null;
    }

    return await response.json();
}

Comparing Native fetch() vs. axios

FeatureNative fetch()Axios Library
Bundle Size0 KB (Built into browser)~13 KB minified
Rejection on 4xx/5xxNo (Resolves, requires !response.ok check)Yes (Automatically rejects Promise)
JSON ParsingManual (await response.json())Automatic (available on res.data)
Request CancellationSupported via AbortControllerSupported via CancelToken / AbortController
InterceptorsRequires custom wrapperBuilt-in request/response interceptors

How to Cancel Pending Fetch Requests

AbortController timeout integration for fetch requests
Implementing request timeout cancellation with AbortController and signal

To prevent memory leaks and state corruption when a user navigates away before a fetch completes, utilize AbortController:

const controller = new AbortController();
const signal = controller.signal;

// Pass signal into fetch options
fetch('https://api.example.com/data', { signal })
    .then(res => res.json())
    .catch(err => {
        if (err.name === 'AbortError') {
            console.log('Fetch successfully aborted');
        } else {
            console.error('Fetch failed:', err);
        }
    });

// To abort the request:
controller.abort();

For more core JavaScript troubleshooting, read our guide on why JavaScript map() returns undefined.

James Parker

James Parker

James Parker is a technology writer and WordPress enthusiast who shares practical guides on SEO, web development, AI tools, and digital growth. He focuses on creating easy-to-follow tutorials that help beginners and professionals build better websites and improve their online presence.