TL;DR
"Failed to fetch" is the TypeError Chrome and Edge throw when a fetch() request never gets a usable HTTP response, because it was blocked, cancelled or couldn't connect. It isn't a status code, so a 404 or a 500 never causes it. Firefox reports the same failure as "NetworkError when attempting to fetch resource", and Safari says "Load failed".
In this guide
Why the error message doesn't tell you the cause
Every cause of a failed fetch produces the same TypeError in your code, on purpose. If JavaScript could tell a CORS rejection from a refused connection, any website could use your browser to probe servers it isn't allowed to read, including ones on your private network. So the page gets one vague error, and the real reason goes somewhere only a person can see it.
Here's what Chrome gave the page's own code for four very different failures:
CORS rejection TypeError: Failed to fetchUnknown host TypeError: Failed to fetchConnection refused TypeError: Failed to fetchMistyped URL scheme TypeError: Failed to fetch
And here's what it printed in the console at the same moment, with the addresses shortened:
Access to fetch at 'https://api.example.com/orders' from origin 'https://shop.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.Failed to load resource: net::ERR_NAME_NOT_RESOLVEDFailed to load resource: net::ERR_CONNECTION_REFUSEDFetch API cannot load htp://example.com/. URL scheme "htp" is not supported.
That's the whole problem in two code blocks. The answer is always in the console or the Network tab, never in error.message. Which also means an error tracker that only stores the message is storing "Failed to fetch" a thousand times and telling you nothing. Log the URL and method alongside it at the very least.
The causes, and how to recognise each one
Six causes cover nearly every case. The console lines are Chrome's, and Edge prints the same ones. Firefox words them differently and, confusingly, blames CORS for some failures that have nothing to do with it: an unknown host comes out as "CORS request did not succeed".
| Cause | What Chrome's console says | What to check |
|---|---|---|
| CORS | has been blocked by CORS policy and the missing header | The API's response headers, for this origin |
| Offline, VPN or DNS | net::ERR_INTERNET_DISCONNECTED or net::ERR_NAME_NOT_RESOLVED | Whether the host resolves from that network |
| Blocked by an extension | net::ERR_BLOCKED_BY_CLIENT | Retry in a private window with extensions off |
| Navigation cancelled it | Often nothing. The Network tab shows the request as (canceled) | A link or form that left the page mid-request |
| Mixed content | Mixed Content: The page at '…' was loaded over HTTPS, but requested an insecure resource | An http:// URL on an https:// page |
| Bad URL or port | net::ERR_CONNECTION_REFUSED, or URL scheme "…" is not supported | Typos, a missing https://, a local API that isn't running |
CORS is the one that wastes the most time, because it looks like a network error and is really a server configuration. The request often reached your API and got a perfectly good answer. The browser just refused to hand it to the page. curl ignores CORS, which makes it the quickest test: if curl gets a 200 and the browser says Failed to fetch, the request works and the headers don't.
Extensions are the one support teams should suspect first for a single customer. Ad and privacy blockers match on URL patterns, so an endpoint called /api/track-order or /analytics/summary can be blocked for that customer and fine for everyone else.
Cancelled navigation catches out a lot of forms. A form whose submit handler calls fetch but never calls event.preventDefault() starts the request, then the browser reloads the page and kills it. The console shows Failed to fetch for a split second, if you catch it at all.
How to find the cause step by step
- Open the developer tools, switch to the Network tab, and tick Preserve log so a page change doesn't wipe the evidence.
- Reproduce the error, then find the red request. Its status column says
CORS error,(blocked:mixed-content),(canceled)or(failed). - Read the matching line in the console. It names the reason the page couldn't see.
- Try the same URL with curl, and in a private window. That separates the server, the browser and the extensions in two goes.
To check CORS from the command line, send the preflight request the browser would send:
curl -i -X OPTIONS https://api.example.com/orders \ -H "Origin: https://shop.example.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: content-type"
The response needs an Access-Control-Allow-Origin header naming your origin (or *, if the request sends no cookies) and an Access-Control-Allow-Headers header that includes content-type. If either is missing, the fix is on the API.
When the error comes from a customer
You can't open the console on someone else's computer, and the customer usually can't tell you what it says. Ask which browser and extensions they use, and whether they're on a VPN or a work network. Then ask for a HAR file, which records every request including the failed one. The HAR checker reads one and strips the secrets before you share it. If you'd like to try the console yourself first, here's how to open it in every browser.
Handling it in your code
Catch it, keep the context, and don't pretend it's always the customer's connection:
async function getJSON(url, options) { let res; try { res = await fetch(url, options); } catch (err) { if (err.name === 'AbortError') throw err; // cancelled on purpose throw new Error(`Request to ${url} failed before a response arrived`, { cause: err }); } if (!res.ok) throw new Error(`${url} returned ${res.status}`); return res.json();}
A "check your internet connection" message for every Failed to fetch is often simply wrong. navigator.onLine === false reliably means offline, but true only means there's a network interface, not that your API is reachable. Show the connection message when you know, and a plain "something went wrong, try again" when you don't.
For support teams
Ask the customer to try once in a private window. If that works, an extension is the likely cause, and it's worth naming it in the ticket so engineering can rename the blocked endpoint rather than chase a bug that only exists in one browser.
More on Console errors