Requests That Trigger a Preflight vs. Ones That Don't — the Real Cause of CORS Errors
A surprising number of "I got a CORS error even though the server headers were clearly set correctly" situations start with a misunderstanding of why — or why not — a preflight request fires in the first place. An OPTIONS preflight is not automatically attached to every cross-origin request. The browser sends requests that satisfy a specific set of conditions known as a "simple request" straight through, with no preflight at all — and the moment a request steps outside those conditions, a preflight is sent first, no exceptions. If you don't know where that line sits, you can tweak your headers all day and never find the actual cause.
1. The three conditions for a simple request
For the browser to treat a request as a "simple request" — one where the preflight gets skipped — it has to satisfy all three of the following conditions at once. First, the method must be one of GET, HEAD, or POST. Second, any header a developer adds on top of what the browser attaches automatically must fall within a restricted list — Accept, Accept-Language, Content-Language, Content-Type, and a few others (adding an Authorization header immediately breaks this condition). Third, the Content-Type must be one of application/x-www-form-urlencoded, multipart/form-data, or text/plain. Only when all three conditions hold does the request go out directly, without a preflight.
2. Why sending JSON always triggers a preflight
The most common API call pattern in web development — fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data)}) — passes condition 1 because the method is POST, but breaks condition 3 because the Content-Type is application/json. As a result it's unconditionally classified as a non-simple request, and the browser fires an OPTIONS preflight before it ever sends the actual POST. If the server doesn't respond to that OPTIONS request with the correct Access-Control-Allow-Methods and Access-Control-Allow-Headers values, the browser blocks the actual POST request before it even reaches the server. That's exactly where the familiar symptom "there's no POST request in the server logs, but the console shows a CORS error" comes from.
fetch('/api/users', {method:'GET'}) → a simple request, sent directly with no preflight. fetch('/api/users', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'}) → a non-simple request; an OPTIONS preflight is sent first, and the actual POST only goes out once the Allow-Methods/Allow-Headers response passes.
| Request characteristics | Preflight? | Notes |
|---|---|---|
| GET (default headers only) | No | The most common simple request |
| POST, Content-Type: application/x-www-form-urlencoded | No | The traditional form-submission method |
| POST, Content-Type: application/json | Yes | Most REST API calls |
| PUT / DELETE / PATCH | Yes | The method itself violates condition 1 |
| Custom headers included (e.g. Authorization) | Yes | Violates condition 2 regardless of method |
3. How MODOO HUB's CORS Header Checker decides this distinction
If you open up the actual code, the CORS Header Checker's analyzeCors() function decides it with const needsPreflight = ['PUT', 'DELETE', 'PATCH'].includes(method);. In other words, choosing PUT, DELETE, or PATCH in the method dropdown makes the tool treat a preflight as required and check the Access-Control-Allow-Methods value; choosing GET, POST, or OPTIONS makes it treat the request as simple, and it judges success purely by Access-Control-Allow-Origin (and whether there's a credentials conflict), regardless of the Allow-Methods value. The code comments even state the principle explicitly: "for a simple request, Allow-Methods has zero effect on the browser's final verdict." That said, this tool uses a simplified model that splits simple/non-simple purely by method — unlike the real spec, it has no separate Content-Type input field, so it doesn't automatically account for the "POST + application/json" case that requires a preflight in real browsers. To simulate that case, you'd need the workaround of switching the method to something like PUT to force the preflight condition.
4. What actually gets checked in the preflight response
The browser checks a preflight (OPTIONS) response and the actual request's response against different criteria. During the preflight step, it checks Access-Control-Allow-Methods (does it include the requested method), Access-Control-Allow-Headers (are all the custom headers on the request allowed), and Access-Control-Allow-Origin. Only once all three of these pass does the browser send out the actual request. A simple request, by contrast, never has a preflight step in the first place, so the browser only checks the actual response's Access-Control-Allow-Origin (and Allow-Credentials, if credentials were requested). If you want to paste in raw header values and diagnose exactly which item passes or fails under which criteria, it helps to pair this with the HTTP Header Checker.
5. A frequently missed trap: Allow-Origin: * and Credentials
Separately from preflight behavior, there's one more mistake that trips people up often. Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true can never be used together. For a request that includes credentials such as cookies or an Authorization header (credentials: 'include'), the server must specify an explicit origin — a wildcard is unconditionally rejected by the browser. This rule applies regardless of whether the request is simple or non-simple, so if you've resolved every preflight issue and requests are still being blocked, this combination is the first thing to suspect. If you want to hit the API server directly with curl to check its headers, the curl Command Generator is handy for that.
Frequently Asked Questions
Q. Does a GET request ever trigger a preflight?
By default, no. But if you add a custom header outside the simple-request allowlist — an Authorization header, for instance — even a GET will trigger a preflight. You need to check the header configuration, not just the method.
Q. If it's a POST and I don't set a Content-Type at all, does the preflight get skipped?
If you don't specify a Content-Type at all, it defaults to text/plain, which can satisfy the simple-request condition. Conversely, explicitly specifying application/json takes you outside that condition and triggers a preflight.
Q. Does a preflight get sent again on every single request?
No. If the server specifies a cache duration via the Access-Control-Max-Age header, the browser caches and reuses the preflight result for the same combination of origin, method, and headers for that duration.
Q. Does MODOO HUB's CORS Header Checker also factor in Content-Type?
No. Based on the actual code, this tool decides whether a preflight is required purely from the request method (whether it's PUT/DELETE/PATCH) and does not separately account for Content-Type. To simulate a case like POST + JSON, where Content-Type is what triggers the preflight, you'd need the workaround of testing with the method switched to something like PUT.