Simple Requests

Let's start with the good news: not every cross-origin request triggers the full CORS negotiation dance. Some requests are considered "simple" enough that the browser just... sends them. No preflight, no OPTIONS request, no extra round trip. The browser fires off your request, gets the response, and then decides whether your JavaScript is allowed to see it.

The bad news? The criteria for what counts as "simple" were frozen in roughly 2004, and they will surprise you.

The Spec Doesn't Actually Say "Simple"

If you go looking for the phrase "simple request" in the Fetch specification, you won't find it. The spec describes requests that "do not need a preflight." The term "simple request" is a colloquialism that stuck — older versions of the spec used it, every blog post uses it, and every Stack Overflow answer uses it. So we'll use it too, but know that the spec has moved on even if the rest of us haven't.

What the spec actually defines is the concept of a CORS-safelisted request — a request whose method and headers fall within a specific, deliberately conservative set. If your request meets all the criteria, the browser skips the preflight. If it doesn't meet even one of them, you get the full OPTIONS-then-actual-request treatment (covered in the next chapter).

The Three Conditions

For a request to qualify as "simple" (no preflight needed), it must satisfy all three of these conditions simultaneously:

Condition 1: Method Must Be GET, HEAD, or POST

That's it. That's the list. No PUT. No DELETE. No PATCH. If you're building a RESTful API and your frontend uses DELETE /api/users/42, congratulations — you've already failed condition one, and a preflight will be sent regardless of anything else.

✅ GET     — simple
✅ HEAD    — simple
✅ POST    — simple (if conditions 2 and 3 also pass)
❌ PUT     — always triggers preflight
❌ DELETE  — always triggers preflight
❌ PATCH   — always triggers preflight
❌ OPTIONS — this is the preflight method itself, so... it's complicated

Why these three? Because these are the methods that HTML forms and <img> tags could already trigger before CORS existed. A <form> element can submit GET and POST requests. A <link> tag triggers GET requests. The browser already allowed these cross-origin without asking anyone's permission, so CORS couldn't retroactively break them.

Condition 2: Only CORS-Safelisted Request Headers

Your request can only include headers from a specific allowlist. Any header outside this list means a preflight. The CORS-safelisted request headers are:

HeaderNotes
Accept
Accept-Language
Content-Language
Content-TypeOnly with specific values — see condition 3
RangeOnly with a simple range value (e.g., bytes=0-1023)

That's the complete list. Five headers. Notice what's not on it:

  • Authorization — sorry, Bearer token fans. Every authenticated API call triggers a preflight.
  • X-Requested-With — the classic "I'm an AJAX request" header that jQuery used to add. Preflight.
  • X-Custom-Anything — any header starting with X- (or any custom header). Preflight.
  • Content-Encoding — want to send gzipped data? Preflight.
  • Cache-Control — want to control caching on the request? Preflight.

There are also byte-length restrictions on some of these. For instance, the value of Accept, Accept-Language, and Content-Language must not exceed 128 bytes. The Content-Type header value is capped at 128 bytes as well. In practice, you'll almost never hit these limits, but the spec is thorough about its paranoia.

Condition 3: Content-Type Must Be One of Three Values

If the request includes a Content-Type header (which POST requests almost always do), its value must be one of exactly three MIME types:

  1. application/x-www-form-urlencoded — what HTML forms send by default
  2. multipart/form-data — what HTML forms send when you have file uploads
  3. text/plain — plain text, no structure implied

And here's the punchline that ruins everyone's afternoon:

application/json is NOT on this list.

Read that again. application/json triggers a preflight. Every single fetch() call that sends JSON to a cross-origin API will trigger a preflight request. Every. Single. One.

// This triggers a preflight. Every time.
fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'  // <-- this is the culprit
  },
  body: JSON.stringify({ name: 'Alice' })
});

This is, without exaggeration, the number-one source of confusion in CORS. People set up their server to handle POST requests, test it with curl (which works perfectly because curl doesn't do CORS), deploy it, and then watch their frontend fail with a preflight error. I have seen this happen to senior engineers. I have seen this happen to me.

Why isn't application/json on the safe list? Because HTML forms couldn't send application/json back when the list was created. The safe list is frozen to the capabilities of circa-2004 web browsers. A <form> element can only produce application/x-www-form-urlencoded and multipart/form-data. The text/plain encoding was added because you could approximate it with a form's enctype attribute. JSON? That's a "new" thing, and "new" means "needs a preflight."

A Complete Simple Request Flow

Let's trace a real simple request from start to finish. Suppose you have:

  • A page loaded from https://app.example.com
  • JavaScript on that page making a GET request to https://api.example.com/users

Step 1: JavaScript Makes the Request

// Running on https://app.example.com
const response = await fetch('https://api.example.com/users');
const users = await response.json();

Step 2: The Browser Checks the Criteria

The browser evaluates:

  • Method: GET — ✅ on the safe list
  • Headers: just the defaults (Accept, Accept-Language, etc.) — ✅ all safelisted
  • Content-Type: none (it's a GET) — ✅ not applicable

Verdict: simple request. No preflight needed.

Step 3: The Browser Sends the Request

The browser sends the request directly, adding the Origin header automatically:

GET /users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Accept: */*
Accept-Language: en-US,en;q=0.9
Connection: keep-alive

Note the Origin header. You didn't set this — the browser added it. You cannot suppress it, spoof it, or modify it from JavaScript. This is the browser telling the server "this request is coming from a page loaded from https://app.example.com." The server uses this to make its access-control decision.

You can see exactly this with curl by simulating what the browser does:

curl -v https://api.example.com/users \
  -H "Origin: https://app.example.com"

Step 4: The Server Responds

If the server is configured to allow cross-origin requests from app.example.com, it responds with:

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Content-Length: 83

[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]

The critical header here is Access-Control-Allow-Origin. The server is saying: "Yes, https://app.example.com is allowed to read this response."

The Vary: Origin header is important too — it tells caches that the response depends on the Origin request header. Without it, a CDN might cache a response that says Access-Control-Allow-Origin: https://app.example.com and serve it to a request from https://other.example.com, which would fail.

Step 5: The Browser Enforces the Policy

The browser receives the response, reads Access-Control-Allow-Origin: https://app.example.com, compares it to the page's origin, and — since they match — hands the response body to your JavaScript. The fetch() promise resolves. The .json() call works. Everyone's happy.

Side by Side

Here's the complete exchange in a table, because visual people deserve nice things too:

RequestResponse
GET /users HTTP/1.1HTTP/1.1 200 OK
Host: api.example.comContent-Type: application/json
Origin: https://app.example.comAccess-Control-Allow-Origin: https://app.example.com
Accept: */*Vary: Origin
Content-Length: 83

What Happens When the Server Doesn't Include CORS Headers

Now for the scenario that fills your browser console with angry red text. Same setup, but this time the server doesn't include any CORS headers:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 83

[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]

The server processed the request. It queried its database. It serialized the JSON. It sent back a perfectly valid 200 response with the data. And the browser throws it all away.

Your JavaScript gets:

Access to fetch at 'https://api.example.com/users' from origin
'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the
requested resource.

The fetch() promise rejects. The response body is inaccessible. Even response.status is hidden (it becomes 0 in some contexts). The browser saw a response without the right CORS headers and said: "I'm not going to let your JavaScript see this."

The Request Was Still Sent

This is the part that catches people off guard. The browser sent the request. The server received it. The server processed it. The server sent back a response. The browser just refused to let JavaScript read that response.

This matters because if your "simple" request has side effects — say, a POST that creates a database record — that side effect happened. The record was created. Your JavaScript just can't see the confirmation.

# You can verify this with curl. This will work fine:
curl -v https://api.example.com/users \
  -H "Origin: https://app.example.com"

# The response comes back with the data.
# curl doesn't enforce CORS. Only browsers do.

This is by design. The browser's job isn't to protect the server — it's to protect the user by controlling what JavaScript can read. The server already got the request (same as it would from a form submission), so blocking it would serve no purpose. But letting arbitrary JavaScript read the response? That's a potential data leak, and that's what CORS prevents.

Seeing This in DevTools

Open Chrome DevTools (F12 or Cmd+Option+I), go to the Network tab, and trigger the failing request. You'll see:

  1. The request appears in the list, often with a status like (failed) or CORS error
  2. Click on the request. You'll see the request headers — including Origin
  3. Click on the Response tab — it'll be empty or show a CORS error message
  4. The Console tab will have the full error message with details about what went wrong

In Firefox, the Network tab is slightly more helpful — it explicitly labels the request as having a "CORS Missing Allow Origin" issue and provides a link to MDN documentation. Chrome's error messages have gotten better over the years, but Firefox has traditionally been the more informative browser for CORS debugging.

A useful DevTools trick: right-click on the column headers in the Network tab and enable the Method column. This helps you distinguish between simple requests (GET/POST) and preflights (OPTIONS) at a glance. For simple requests that fail CORS, you'll see only one row. For preflight failures (next chapter), you'll see two — the OPTIONS and the actual request.

The Wildcard: Access-Control-Allow-Origin: *

Instead of specifying a particular origin, the server can respond with:

Access-Control-Allow-Origin: *

This means "any origin can read this response." It's appropriate for truly public APIs and static resources. It is not a security vulnerability in itself — but it does have restrictions when credentials (cookies) are involved, which we'll cover in Chapter 8.

# Testing a public API with the wildcard
curl -v https://api.publicdata.example.com/weather \
  -H "Origin: https://literally-anything.com"

# Response:
# HTTP/1.1 200 OK
# Access-Control-Allow-Origin: *
# Content-Type: application/json

Common Mistakes with Simple Requests

Mistake 1: "I'll just set Content-Type to text/plain to avoid preflights"

Yes, technically, you can send JSON with Content-Type: text/plain and avoid the preflight:

fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'text/plain'  // sneaky
  },
  body: JSON.stringify({ name: 'Alice' })
});

Please don't do this. Your server-side framework probably uses Content-Type to decide how to parse the body. Sending JSON as text/plain means your body parser won't kick in, and you'll end up writing custom parsing logic. You're fighting the web platform to save one HTTP request. Just handle the preflight properly.

Mistake 2: "My GET request is triggering a preflight"

If your GET request is triggering a preflight, you've added a non-safelisted header. The usual suspects:

// This is NOT a simple request despite being GET
fetch('https://api.example.com/users', {
  headers: {
    'Authorization': 'Bearer eyJhbGc...',  // <-- preflight trigger
  }
});

Every authenticated API request triggers a preflight. There's no way around this. Accept it, configure your server to handle OPTIONS, and move on with your life.

Mistake 3: "CORS is blocking my request"

No, CORS is blocking your JavaScript from reading the response. The distinction matters because:

  1. For simple requests, the server received and processed your request
  2. The "block" is on the response, not the request
  3. If you're trying to debug whether your server received the request, check your server logs — it did

A Note on no-cors Mode

You might have seen this:

fetch('https://api.example.com/users', {
  mode: 'no-cors'
});

This tells the browser: "I don't need to read the response, just send the request." The browser will send the request, but the response will be an opaque responseresponse.type is "opaque", response.status is 0, and the body is inaccessible. This is useful for things like sending analytics pings or warming caches, but it is almost certainly not what you want for API calls. If someone on Stack Overflow tells you to add mode: 'no-cors' to fix a CORS error, they are technically making the error go away in the same way that closing your eyes makes the check engine light go away.

Summary

A request skips the preflight if and only if:

  1. The method is GET, HEAD, or POST
  2. The only headers are from the CORS-safelisted set (Accept, Accept-Language, Content-Language, Content-Type, Range)
  3. If Content-Type is present, it's application/x-www-form-urlencoded, multipart/form-data, or text/plain

If any of those conditions fail, the browser sends a preflight OPTIONS request first, which is the subject of our next chapter. And since most modern API calls use application/json or include an Authorization header, most of your requests will need a preflight. The "simple request" path is really the exception, not the rule — a compatibility shim for the pre-CORS web that happens to still be useful for basic GET requests to public APIs.