CORS error: what the browser is actually blocking
A CORS error is the browser refusing a response the server already returned with a 200. Here is that proved with two real origins, and the fixes.

A CORS error is not a failed request. The request usually succeeded, the server usually returned your data, and the browser then refused to give it to your JavaScript because the response did not carry a header saying your origin was allowed.
That is the whole idea, and once you believe it the fixes stop feeling arbitrary. Cross-origin resource sharing is the server's way of saying which other sites may read its responses. No header means no permission, and the browser enforces that on the server's behalf.
Everything below was measured with two real servers, an API on localhost:4301 and a page on localhost:4302, driven by a real browser.
Proof that the server already said yes
Here is the same request made twice. First without a browser:
[no Access-Control-Allow-Origin header] HTTP 200
content-type: application/json
body: {"ok":true,"from":"api:4301"}
Status 200. The full body came back. The server has no complaint at all.
Now the identical request, from a page on a different origin, in a browser:
fetch("http://localhost:4301/data")
.then((r) => r.json())
.catch((e) => console.log(e.constructor.name + ": " + e.message));
// TypeError: Failed to fetch
Same server, same route, same 200 response. The browser received it and threw it away.
Adding one header to the response changes it:
res.setHeader("Access-Control-Allow-Origin", "*");
With that in place the browser call returns { ok: true, from: "api:4301" }. Setting the exact origin instead of * works identically:
res.setHeader("Access-Control-Allow-Origin", "http://localhost:4302");
res.setHeader("Vary", "Origin");
Nothing about the server's behaviour changed except one line of metadata. This is why "it works in Postman" and "it works in curl" tell you nothing: neither is a browser, so neither enforces the rule.
Why the error never says CORS
The message your code catches is TypeError: Failed to fetch. It does not mention CORS, the origin, or the missing header.
That is deliberate. Telling the page why it was blocked would leak information about a server the page is not allowed to read. The detailed explanation goes to the console, where the developer can see it and the page cannot.
So there are two places to look, and they say different things:
- The Console names CORS and the missing header. This is the only place the reason appears.
- The Network tab shows the request, its status, and the response headers it did or did not have.
Checking the Network tab is the step most people skip, and it is the one that settles the diagnosis. A request showing a 200 with no Access-Control-Allow-Origin in the response headers is a CORS problem. A request showing a 404 or 500 is a different problem wearing the same Failed to fetch message. If reading that panel is new to you, Chrome DevTools for beginners covers it.
The preflight, and the request that never happens
Some requests get checked before they are sent. The browser sends an OPTIONS request first, asks whether the real one is allowed, and only proceeds if the answer permits it.
This is where the confusion gets worse, because the failure looks identical from the page. Here is what the API server actually received in three runs. The list is the server's own log:
| What the page sent | Server received | Result |
|---|---|---|
Plain GET |
GET /data |
Works |
GET with an X-Api-Key header |
OPTIONS /data |
TypeError: Failed to fetch |
Same, with Access-Control-Allow-Headers set |
OPTIONS /data, GET /data |
Works |
Look at the middle row. The server never received the GET. The browser asked permission, did not get it, and cancelled the real request. Your endpoint was never called, your handler never ran, and your server logs show an OPTIONS you were not expecting and nothing else.
That explains a support conversation that happens constantly: the frontend developer says the API is broken, the backend developer checks the logs, sees no request, and says the frontend never called it. Both are describing the same preflight.
A request is preflighted when it uses a method other than GET, HEAD or POST, or sets a header outside a small allowed set. Adding an Authorization or X-Api-Key header is enough to trigger it. To handle it, answer OPTIONS with the permissions:
res.setHeader("Access-Control-Allow-Origin", "http://localhost:4302");
res.setHeader("Access-Control-Allow-Methods", "GET,POST");
res.setHeader("Access-Control-Allow-Headers", "Content-Type,X-Api-Key");
res.writeHead(204);
Every custom header the page sends must be named in Access-Control-Allow-Headers. Missing one is enough to block the request.
The three real fixes
Set the header on the server. This is the correct fix whenever you control the API. In Express, the cors package does it:
import cors from "cors";
app.use(cors({ origin: "https://yourapp.com" }));
Name your origins rather than using * in production. A wildcard means any site on the internet can read the responses, which is fine for a public API and wrong for anything behind a login.
Proxy through your dev server. When you do not control the API, route the call through your own origin so the browser never sees a cross-origin request. Most dev servers have this built in, and it removes the problem in development without changing anyone's server.
Put them on the same origin. Serving the API under a path on the same domain, such as /api, means there is no cross-origin request to permit. This is why full-stack frameworks rarely hit CORS at all.
There is one more rule worth knowing before it catches you. Access-Control-Allow-Origin: * stops working the moment you send cookies or credentials. With credentials: "include", the browser requires an exact origin and Access-Control-Allow-Credentials: true, and rejects the wildcard outright.
When it only breaks in production
A CORS error that appears after deploying, on code that worked all week, is almost always the dev proxy disappearing.
In development, the proxy made every API call same-origin, so no permission was ever needed. In production the built files are served from a real domain and call the API at its real domain, which is a genuine cross-origin request for the first time. The header was always missing. Nothing was enforcing it until now.
The reverse case exists too and is easier to miss. Adding a staging domain, a preview deployment with a generated URL, or a custom domain gives you a new origin that the server's allow list has never heard of. If the server names its origins explicitly, and it should, then every new front end needs adding to that list.
Two habits prevent both:
- Read the allowed origins from configuration rather than hard-coding them, so a new environment is a config change and not a code change.
- Test at least one cross-origin call before you deploy, rather than relying on a proxy that only exists locally.
A quick triage
When Failed to fetch appears, this order finds the cause fastest:
| Check | If it is true |
|---|---|
| Does the Network tab show the request at all? | No request means a wrong URL or a blocked mixed-content call, not CORS |
Is the method OPTIONS and nothing else? |
A preflight was rejected; check Allow-Methods and Allow-Headers |
Is the status 200 with no Access-Control-Allow-Origin? |
The header is missing; add it on the server |
| Is the status 404 or 500? | Fix the endpoint; this is not a CORS problem |
Are you sending credentials with * as the origin? |
Replace * with the exact origin and allow credentials |
The second row is the one worth internalising. A lone OPTIONS in your server logs is the signature of a blocked preflight, and it means your real handler was never reached.
The fixes that are not fixes
A browser extension that disables CORS. It edits the response on the way into your browser. It changes nothing on the server, so it works on your machine and for nobody else. Worse, it hides the problem during the exact phase where you would otherwise fix it.
Launching the browser with web security disabled. Same objection, plus you are now browsing the rest of the internet without a protection that exists for good reasons.
A public CORS proxy. You have routed your users' requests, including anything they send, through a stranger's server.
All three share a shape: they change the client, and CORS is a decision made by the server. If the header is not there, the answer is to add it, proxy around it, or remove the cross-origin boundary.
Want to see the request and response pair for yourself? Start with the JavaScript track and watch it happen in the Network tab.
More from the blog

git cherry-pick: move one commit anywhere
Cherry-pick copies a commit onto your current branch. It creates a new commit with a new hash, so the original stays where it was.
Read more
fatal: not a git repository, and how to get out of it
fatal: not a git repository means git found no .git folder here or in any parent. Four causes, and the popular fix that quietly makes things worse.
Read moreReady to write some code?
Put this into practice - start your first free lesson. No setup, no credit card.