CORS with Fetch and XMLHttpRequest
So you've learned what CORS is, how preflight works, and why the browser is doing
this to you. Now let's talk about the two APIs you'll actually use to make
cross-origin requests: fetch() and XMLHttpRequest. They handle CORS
differently in subtle, sometimes infuriating ways. Let's get into it.
How fetch() Handles CORS by Default
Here's the first thing that surprises people: fetch() uses CORS mode by
default. You don't have to opt into it. When you write:
fetch("https://api.example.com/data")
The browser silently sets the request mode to "cors". This means:
- The browser will add an
Originheader to the request. - The browser will inspect the response for
Access-Control-Allow-Origin. - If the CORS check fails, your JavaScript gets nothing. Not a 403. Not an error message. Nothing useful.
This is the default, and honestly, it's the right default. You almost always want CORS enforcement when talking to a different origin. The problems start when people discover the other modes and think they've found an escape hatch.
fetch() Mode Options
The fetch() function accepts a mode option with four possible values:
fetch(url, { mode: "cors" }) // Default. Standard CORS.
fetch(url, { mode: "no-cors" }) // The trap. We'll get to this.
fetch(url, { mode: "same-origin" }) // Reject cross-origin requests entirely.
fetch(url, { mode: "navigate" }) // Used by the browser for navigation. You can't use this.
Let's walk through each one.
mode: "cors" (the default)
This is what you get when you don't specify a mode. The browser performs a full
CORS check. If the server doesn't send proper CORS headers, the request is
blocked. If the server does send them, you get a full Response object with
readable headers and body.
const response = await fetch("https://api.example.com/users", {
mode: "cors", // You don't actually need this line; it's the default
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIs..."
}
});
// If CORS succeeds, you can read the body:
const data = await response.json();
console.log(data);
The corresponding response headers from a properly configured server:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining
Content-Type: application/json
X-Request-Id: abc-123-def
X-RateLimit-Remaining: 42
mode: "same-origin"
This one is straightforward. If the request is cross-origin, the browser rejects it immediately without even making a network request:
// If your page is on https://myapp.example.com:
fetch("https://api.example.com/data", { mode: "same-origin" })
.catch(err => {
// TypeError: Failed to fetch
// The request never leaves the browser.
});
Use this when you want to guarantee you're only talking to your own origin. It's a good defensive pattern for internal API calls that should never go cross-origin.
mode: "navigate"
This is used internally by the browser for page navigation (clicking links, submitting forms, entering URLs). You cannot use it from JavaScript. If you try, the browser will throw. I mention it only for completeness and because you'll see it in specs.
mode: "no-cors" (The Trap)
Now we arrive at the mode that has caused more confusion, frustration, and wasted hours than probably any other single option in the Fetch API. Let me be very clear upfront:
mode: "no-cors" does not bypass CORS. It does not disable CORS. It does not
make CORS go away.
What it does is tell the browser: "I know this request might not have CORS headers in the response. Make the request anyway, but give me an opaque response that I can't read."
const response = await fetch("https://api.example.com/data", {
mode: "no-cors"
});
console.log(response.type); // "opaque"
console.log(response.status); // 0
console.log(response.ok); // false
// This will be empty:
const text = await response.text();
console.log(text); // ""
You read that right. The response body is empty. The status is 0. The headers are inaccessible. You've successfully made a request and gotten absolutely nothing useful back.
"But the request went through!" you say. Yes, it did. The server received it and sent back a response. The browser just won't let you see it.
When is no-cors actually useful? Almost never in application code. It
exists primarily for service workers that need to cache opaque responses (like
third-party resources that don't support CORS). If you're writing application
logic and you reach for no-cors, stop. Fix the server's CORS headers instead.
There's another restriction that catches people: in no-cors mode, you can only
use "CORS-safelisted" request headers. You can't set Authorization, custom
headers, or even Content-Type values other than application/x-www-form-urlencoded,
multipart/form-data, and text/plain. The browser will silently strip any
headers that aren't safelisted.
// This Authorization header will be SILENTLY REMOVED:
fetch("https://api.example.com/data", {
mode: "no-cors",
headers: {
"Authorization": "Bearer token123" // Stripped. Gone. No warning.
}
});
I've seen people spend hours debugging why their auth isn't working, only to
discover that no-cors was quietly eating their headers.
XMLHttpRequest CORS Behavior
For those of us who remember (or are maintaining) code that uses XMLHttpRequest,
CORS works a bit differently. XHR doesn't have a mode option. Instead, CORS is
triggered automatically whenever you make a cross-origin request:
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data");
xhr.setRequestHeader("Content-Type", "application/json");
xhr.onload = function() {
console.log(xhr.status); // 200 (if CORS passes)
console.log(xhr.responseText); // The response body
};
xhr.onerror = function() {
// CORS failure lands here. Good luck figuring out why.
// xhr.status will be 0
// xhr.statusText will be ""
console.log("Something went wrong. CORS? Network? Who knows.");
};
xhr.send();
withCredentials
The big CORS-related property on XHR is withCredentials. This is analogous to
fetch()'s credentials: "include":
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data");
xhr.withCredentials = true; // Send cookies cross-origin
xhr.onload = function() {
console.log(xhr.responseText);
};
xhr.send();
When withCredentials is true, the server must respond with:
Access-Control-Allow-Originset to the specific origin (not*)Access-Control-Allow-Credentials: true
The fetch() equivalent:
fetch("https://api.example.com/data", {
credentials: "include"
});
Here's a curl simulation showing what the browser sends:
# Simulating a credentialed cross-origin request
curl -v https://api.example.com/data \
-H "Origin: https://myapp.example.com" \
-H "Cookie: session=abc123" \
2>&1
# The server must respond with:
# Access-Control-Allow-Origin: https://myapp.example.com
# Access-Control-Allow-Credentials: true
# (NOT Access-Control-Allow-Origin: *)
Reading Response Headers: The Safelisted Set
Here's something that bites people regularly: even when CORS succeeds, you can't read all response headers from JavaScript. By default, only these CORS-safelisted response headers are readable:
Cache-ControlContent-LanguageContent-LengthContent-TypeExpiresLast-ModifiedPragma
That's it. Everything else is hidden from your JavaScript unless the server explicitly exposes it.
const response = await fetch("https://api.example.com/data");
// These work (safelisted):
console.log(response.headers.get("Content-Type")); // "application/json"
console.log(response.headers.get("Content-Length")); // "1234"
// These return null even if the server sent them:
console.log(response.headers.get("X-Request-Id")); // null
console.log(response.headers.get("X-RateLimit-Remaining")); // null
console.log(response.headers.get("ETag")); // null (yes, really)
Open DevTools, look at the Network tab, and you'll see all the headers right there in the response. But your JavaScript can't read them. This is one of those moments where DevTools actively misleads you, because it shows you what was on the wire, not what your code has access to.
Access-Control-Expose-Headers
To make additional headers readable, the server must include
Access-Control-Expose-Headers:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.example.com
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining, ETag
Content-Type: application/json
X-Request-Id: req-789
X-RateLimit-Remaining: 98
ETag: "v1-abc123"
Now your JavaScript can read those headers:
const response = await fetch("https://api.example.com/data");
console.log(response.headers.get("X-Request-Id")); // "req-789"
console.log(response.headers.get("X-RateLimit-Remaining")); // "98"
console.log(response.headers.get("ETag")); // "v1-abc123"
You can also use the wildcard * to expose all headers:
Access-Control-Expose-Headers: *
But be aware: the wildcard does not work when Access-Control-Allow-Credentials
is true. With credentialed requests, you must list each header explicitly. The
spec authors really don't want you using wildcards with credentials, and they
mean it.
Error Handling: The Opacity of CORS Failures
This is, in my opinion, one of the most frustrating aspects of CORS from a developer experience perspective. When a CORS request fails, the browser deliberately hides the reason from your JavaScript.
try {
const response = await fetch("https://api.example.com/data");
} catch (error) {
// error.message is something like "Failed to fetch"
// That's it. That's all you get.
// Was it CORS? DNS? Network down? Server 500? Connection refused?
// Your code cannot tell.
console.log(error.message); // "Failed to fetch" or "NetworkError"
}
This is intentional. If the browser told your JavaScript why the CORS check
failed, it would leak information about the target server's configuration. An
attacker could use that to probe internal networks. So the browser gives you a
generic TypeError and calls it a day.
With XHR, it's the same story:
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data");
xhr.onerror = function() {
console.log(xhr.status); // 0
console.log(xhr.statusText); // ""
// Extremely helpful.
};
xhr.send();
How to Actually Debug CORS Errors
Your code can't distinguish CORS errors from network errors. But you can, as a developer, using browser DevTools:
-
Open the Console tab. The browser will print a detailed CORS error message that explains exactly what went wrong. It might say:
Access to fetch at 'https://api.example.com/data' from origin 'https://myapp.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. -
Open the Network tab. Look for the failed request. If it's a CORS failure:
- The request will show as
(failed)or with a status of0. - Click on it and look at the response headers. If there are no CORS headers, that's your problem.
- If it was a preflight failure, you'll see two entries: the OPTIONS request and the actual request. The OPTIONS request likely has the issue.
- The request will show as
-
Check the Console for preflight-specific errors. Chrome is pretty good about telling you exactly which header or method was rejected:
Access to fetch at 'https://api.example.com/data' from origin 'https://myapp.example.com' has been blocked by CORS policy: Request header field x-custom-token is not allowed by Access-Control-Allow-Headers in preflight response. -
Use curl to test the server directly. Simulate what the browser does:
# Test a simple GET curl -v https://api.example.com/data \ -H "Origin: https://myapp.example.com" \ 2>&1 | grep -i "access-control" # Test a preflight curl -v -X OPTIONS https://api.example.com/data \ -H "Origin: https://myapp.example.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Content-Type, Authorization" \ 2>&1 | grep -i "access-control"
Distinguishing CORS Errors from Network Errors in Code
While you can't get a detailed reason, you can use a few heuristics:
async function fetchWithDiagnostics(url, options = {}) {
try {
const response = await fetch(url, options);
return response;
} catch (error) {
// At this point, error is a TypeError for both CORS and network failures.
// Heuristic 1: Can we reach the server at all?
// Try a no-cors request. If it succeeds, the server is reachable,
// so the original failure was likely CORS.
try {
await fetch(url, { mode: "no-cors", method: "HEAD" });
// Server is reachable. Original failure was probably CORS.
throw new Error(
`CORS error: The server at ${new URL(url).origin} didn't ` +
`include proper CORS headers. Check the server configuration.`
);
} catch (innerError) {
if (innerError.message.startsWith("CORS error:")) {
throw innerError; // Re-throw our diagnostic error
}
// no-cors also failed, so it's likely a network issue
throw new Error(
`Network error: Could not reach ${new URL(url).origin}. ` +
`Check your connection and that the server is running.`
);
}
}
}
This isn't foolproof, but it's better than nothing.
The Response.type Property
Every Response object has a type property that tells you what kind of
response you got:
const response = await fetch("https://api.example.com/data");
console.log(response.type);
The possible values:
| Type | Meaning |
|---|---|
"basic" | Same-origin response. All headers readable. |
"cors" | Cross-origin response that passed CORS. Safelisted + exposed headers readable. |
"opaque" | From a no-cors request. Status 0, no headers, empty body. |
"opaqueredirect" | From a no-cors request that got redirected. Even less useful. |
"error" | Network error. You'll typically see this in service workers. |
In practice, when debugging, check response.type early:
const response = await fetch(url);
if (response.type === "opaque") {
console.warn(
"Got an opaque response. Did you accidentally set mode: 'no-cors'? " +
"You can't read anything from this response."
);
}
if (response.type === "cors") {
// Normal cross-origin response. Safe to read body and exposed headers.
const data = await response.json();
}
if (response.type === "basic") {
// Same-origin. Everything is available.
const data = await response.json();
}
Practical Examples with Real APIs
Example 1: Fetching from a Public API
Many public APIs set Access-Control-Allow-Origin: *, which makes life easy:
// JSONPlaceholder is a free test API with permissive CORS
const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
console.log(response.type); // "cors"
console.log(response.ok); // true
const post = await response.json();
console.log(post.title);
Verify the CORS headers with curl:
$ curl -sI https://jsonplaceholder.typicode.com/posts/1 \
-H "Origin: https://example.com" | grep -i access-control
Access-Control-Allow-Origin: *
Example 2: Authenticated Request to Your Own API
const response = await fetch("https://api.myapp.com/user/profile", {
method: "GET",
credentials: "include",
headers: {
"Authorization": "Bearer eyJhbGciOiJSUzI1NiIs...",
"Accept": "application/json"
}
});
if (!response.ok) {
// Note: if this was a CORS failure, you'd be in the catch block,
// not here. A non-ok status means the request went through but
// the server returned an error (4xx, 5xx).
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const profile = await response.json();
This request will trigger a preflight because of the Authorization header.
The preflight exchange looks like:
>>> OPTIONS /user/profile HTTP/1.1
>>> Host: api.myapp.com
>>> Origin: https://myapp.com
>>> Access-Control-Request-Method: GET
>>> Access-Control-Request-Headers: authorization, accept
<<< HTTP/1.1 204 No Content
<<< Access-Control-Allow-Origin: https://myapp.com
<<< Access-Control-Allow-Methods: GET, POST, PUT, DELETE
<<< Access-Control-Allow-Headers: Authorization, Accept, Content-Type
<<< Access-Control-Allow-Credentials: true
<<< Access-Control-Max-Age: 86400
Example 3: The Classic Mistake
// Developer's inner monologue: "CORS keeps blocking me, let me try no-cors"
const response = await fetch("https://api.example.com/data", {
mode: "no-cors",
headers: {
"Authorization": "Bearer mytoken" // Silently stripped
}
});
// "It works! No more CORS errors!"
console.log(response.status); // 0
const data = await response.text(); // ""
// "...why is the response empty?"
Don't be this developer. I've been this developer. It's not fun.
AbortController and CORS Timeout Patterns
CORS preflight can add latency to your requests. The browser has to make an
OPTIONS request, wait for the response, and then make the actual request.
For slow servers or high-latency connections, this can be painful. Using
AbortController to set timeouts is a solid pattern:
async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
return response;
} catch (error) {
if (error.name === "AbortError") {
throw new Error(
`Request to ${url} timed out after ${timeoutMs}ms. ` +
`If this is a cross-origin request, the timeout includes ` +
`preflight negotiation time.`
);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
// Usage:
try {
const response = await fetchWithTimeout(
"https://api.example.com/data",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "test" })
},
5000 // 5 second timeout
);
const data = await response.json();
} catch (error) {
console.error(error.message);
}
Racing Multiple Requests with Abort
You can also use AbortController to cancel in-flight CORS requests when a
component unmounts or when you only care about the latest request:
let currentController = null;
async function searchAPI(query) {
// Cancel any in-flight request
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
try {
const response = await fetch(
`https://api.example.com/search?q=${encodeURIComponent(query)}`,
{ signal: currentController.signal }
);
return await response.json();
} catch (error) {
if (error.name === "AbortError") {
// Request was cancelled because a newer search started.
// This is expected, not an error.
return null;
}
throw error; // Re-throw actual errors (including CORS failures)
}
}
Note that aborting a request during the preflight phase works correctly. The browser will cancel the OPTIONS request if it hasn't completed yet, and the actual request will never be sent.
Summary
| Topic | fetch() | XMLHttpRequest |
|---|---|---|
| Default CORS mode | "cors" (automatic) | Always CORS for cross-origin |
| Send credentials | credentials: "include" | withCredentials = true |
| Disable CORS | You can't. no-cors gives opaque responses. | You can't. |
| Error details | TypeError: Failed to fetch | status: 0, statusText: "" |
| Response type info | response.type property | Not available |
| Abort support | AbortController | xhr.abort() |
| Streaming | Yes (ReadableStream) | Limited (progress events) |
The core lesson: CORS is enforced by the browser, not by your JavaScript code. You cannot bypass it from the client side. If you're hitting CORS issues, the fix is on the server. Always. No exceptions. (Well, except for proxies, but that's a different chapter.)