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:

  1. The browser will add an Origin header to the request.
  2. The browser will inspect the response for Access-Control-Allow-Origin.
  3. 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-Origin set 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-Control
  • Content-Language
  • Content-Length
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

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:

  1. 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.
    
  2. 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 of 0.
    • 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.
  3. 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.
    
  4. 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:

TypeMeaning
"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

Topicfetch()XMLHttpRequest
Default CORS mode"cors" (automatic)Always CORS for cross-origin
Send credentialscredentials: "include"withCredentials = true
Disable CORSYou can't. no-cors gives opaque responses.You can't.
Error detailsTypeError: Failed to fetchstatus: 0, statusText: ""
Response type inforesponse.type propertyNot available
Abort supportAbortControllerxhr.abort()
StreamingYes (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.)