Home

Fix this error · Browser and network

Fix: TypeError: Failed to fetch

What TypeError: Failed to fetch means in Chrome, Firefox and Safari, how to find the real cause in DevTools, and a fix for each cause.

fetch() rejects with this error when the browser could not get a usable response at all: the request was blocked, the server could not be reached, or the connection dropped. A 404 or 500 does not cause it. To fix it, open the failed request in the DevTools Network tab and read the real reason there. The most common ones are CORS, a server that is down or unreachable, mixed content and ad blockers.

FactDetail
ErrorTypeError: Failed to fetch (Chrome, Edge)
FirefoxTypeError: NetworkError when attempting to fetch resource.
SafariTypeError: Load failed
Where you see itBrowser console, your catch block, error logs from the browser
Usual causeCORS, server down or wrong URL, mixed content, ad blocker, offline
Usual fixFind the reason in DevTools (console line above it, Network status column), then fix that
BugTape catches itYes, as a failed request when it follows a click within 8 seconds. Other requests need one line

What the error means

fetch() only rejects when the request fails. When the server answers with any HTTP status, including 404 or 500, the promise resolves and res.ok is false. When the browser cannot get a response it is allowed to give you, the promise rejects with a TypeError. The message is deliberately vague, so pages cannot probe other servers. Chrome says Failed to fetch, Firefox says NetworkError when attempting to fetch resource. and Safari says Load failed.

The real reason is almost always printed elsewhere: a CORS or Mixed Content message in the console just above the error, or a net::ERR_… code in the Network tab (for example ERR_CONNECTION_REFUSED, ERR_NAME_NOT_RESOLVED, ERR_BLOCKED_BY_CLIENT, ERR_INTERNET_DISCONNECTED).

If the request was cancelled with an AbortController, fetch() rejects with an AbortError instead. That is a different error.

Common causes

  1. CORS. The API is on another origin and does not send Access-Control-Allow-Origin for your site, or does not answer the OPTIONS preflight. The console shows … has been blocked by CORS policy.
  2. The server is down, or the URL is wrong. Nothing is listening on that host and port (ERR_CONNECTION_REFUSED), the hostname does not resolve (ERR_NAME_NOT_RESOLVED), or the API base URL points to localhost in production.
  3. Mixed content. An HTTPS page calls an http:// URL. The browser blocks it and logs Mixed Content: … This request has been blocked.
  4. An ad blocker or privacy extension. URLs containing words like analytics, track or ads are blocked. The Network tab shows ERR_BLOCKED_BY_CLIENT.
  5. The user is offline or the connection dropped. Mobile networks, VPNs and captive portals.
  6. An invalid request. A URL with a username and password in it, a malformed URL, or an invalid option in the fetch() call.
  7. The page navigated away during the request. The browser cancels in-flight requests when the page unloads.
  8. A TLS problem. An expired or self-signed certificate on the API host (ERR_CERT_…).

How to fix it

Read the reason in DevTools

Open DevTools, then the Network tab, and repeat the action. Click the red row.

  • Status (failed) with net::ERR_CONNECTION_REFUSED or ERR_NAME_NOT_RESOLVED: the server or URL. Check that the API is running and the URL is right.
  • Status (blocked:mixed-content): an http:// URL on an HTTPS page.
  • ERR_BLOCKED_BY_CLIENT: an extension. Try a private window with extensions off.
  • CORS error, or a console line blocked by CORS policy: fix the headers on the API server.

Fix CORS on the server

The server you call must allow your origin. In Express:

import cors from 'cors';

app.use(cors({ origin: ['https://app.example.com'], credentials: true }));

In a Next.js route handler, return Access-Control-Allow-Origin on every response and export an OPTIONS handler. If the frontend and API are the same Next.js app, call a relative URL (fetch('/api/data')) and CORS does not apply. The full set of fixes is in the CORS guide.

Point production at the right API URL

A base URL that falls back to localhost works on your machine and fails for every user. Read it from an environment variable that is set at build time, and fail the build when it is missing.

// Vite: import.meta.env; Next.js: process.env.NEXT_PUBLIC_API_URL
const API_URL = import.meta.env.VITE_API_URL;
if (!API_URL) throw new Error('VITE_API_URL is not set');

const res = await fetch(`${API_URL}/items`);

Next.js and Vite inline these values when they build, so changing the variable needs a new build.

Use HTTPS for the API

On an HTTPS page every fetch() URL must also be HTTPS. Change http:// to https:// in the URL or the environment variable. See the mixed content guide.

Move first-party requests off blocked paths

Ad blockers match URL patterns. If your own endpoint is called /api/track or /analytics/event, rename it. You cannot stop users from running an extension, so handle the error and keep the page usable.

Handle the error in your code

Treat a rejection and a bad status as two different failures, and tell the user something useful.

async function getItems() {
  let res: Response;
  try {
    res = await fetch('/api/items');
  } catch (err) {
    if ((err as Error).name === 'AbortError') return null; // we cancelled it
    throw new Error(`GET /api/items could not connect: ${(err as Error).message}`, { cause: err });
  }
  if (!res.ok) throw new Error(`GET /api/items returned ${res.status}`);
  return res.json();
}

Retry when the user is offline

navigator.onLine is false when the browser knows it has no connection. Wait for the online event before you retry, and retry a small number of times with a delay.

async function fetchWithRetry(url: string, init?: RequestInit, tries = 3): Promise<Response> {
  for (let attempt = 1; ; attempt++) {
    try {
      return await fetch(url, init);
    } catch (err) {
      if (attempt >= tries || (err as Error).name === 'AbortError') throw err;
      if (!navigator.onLine) await new Promise((r) => window.addEventListener('online', r, { once: true }));
      else await new Promise((r) => setTimeout(r, 500 * attempt));
    }
  }
}

Only retry requests that are safe to repeat, such as GET, or POST requests that use an idempotency key.

Cancel requests on purpose when the page changes

In React, cancel a request when the component unmounts. The rejection is then an AbortError that you can ignore.

useEffect(() => {
  const controller = new AbortController();
  fetch('/api/items', { signal: controller.signal })
    .then((res) => res.json())
    .then(setItems)
    .catch((err) => { if (err.name !== 'AbortError') reportError(err); });
  return () => controller.abort();
}, []);

How to catch it in production with BugTape

The BugTape browser SDK records every fetch and XHR. When one fails with a network error within 8 seconds of a click, BugTape reports it as User-triggered request failed: /api/items (failed to fetch), with the method, the full request URL, the element the user clicked, the replay of the page and the other requests around it. The text in brackets is the browser's own message, so Firefox and Safari reports read networkerror when attempting to fetch resource. and load failed. Repeats of the same request from the same element become one issue with a count.

What BugTape does not report on its own:

  • Requests that are not tied to a click. A request on page load, on a timer or from a background poll is recorded in the network list of any report, but does not start one. Catch the error and call reportError(err), which fires the window error event the SDK listens for, as in the useEffect example above.
  • Aborted requests. A request cancelled with AbortController is skipped, so unmounting components do not create noise.
  • Requests to BugTape itself and to common analytics, ads and monitoring hosts. These are ignored.
  • Anything before init() runs.

The CORS and Mixed Content lines are printed by the browser itself and do not pass through console, so they are not in the recorded console output. The request URL in the report usually shows the cause: another origin points to CORS, an http:// URL to mixed content. An await fetch() with no catch is also reported, as Unhandled promise rejection: Failed to fetch.

import { init } from '@bugtape/sdk';

init({ apiKey: 'YOUR_PROJECT_KEY', release: import.meta.env.VITE_COMMIT_SHA });

Set release so a fix that does not hold shows up: when you mark the issue fixed, a report from a newer release reopens it as a regression. Your coding agent can read the issue over MCP (npx -y bugtape connect claude, or cursor or codex), see the failing URL and what the user did, and propose a change. You review the pull request.

FAQ

Why does fetch not throw on a 404 or 500?

fetch() rejects only when there is no usable response. A 404 or 500 is a response, so the promise resolves and res.ok is false. Check res.ok or res.status yourself and throw when it is not what you expect.

Why does the request work in Postman but fail in the browser?

Postman does not enforce CORS or mixed content rules and does not run browser extensions. The browser does all three. Look for a CORS or Mixed Content line in the console, or ERR_BLOCKED_BY_CLIENT in the Network tab.

Why do I see "Failed to fetch" only for some users?

The usual reasons are on the user's side: an ad blocker, a corporate proxy or VPN, a flaky mobile connection, or an old cached page that calls an API URL you have since changed. Errors that hit everyone point to your server, URL or CORS configuration.

Can I get the real reason from the error object?

No. The browser hides the reason from the page on purpose, so the TypeError message is the same for CORS, DNS and refused connections. The reason is only shown in DevTools. In production, the request URL, the page's origin and whether the user was online are the best clues.

Sources

All guides: Fix an error.

Start free. Free plan · No credit card · Set up in minutes. Or try the live demo first.