Every so often someone opens the network tab, points at a single API call, and asks the same question: why are there two rows? One says OPTIONS, the next says GET, both hitting the same URL. The code only made one request.
That extra OPTIONS row is the CORS preflight. Once you see what it's doing, it stops looking like a bug and starts looking like the browser doing its job.
The rule the browser enforces
Think of every website as living at an address. The address is the scheme, the domain, and the port together — https://app.example.com is a different address from https://api.example.com, and even a different port counts as a different place.
By default, a page loaded from one address is not allowed to go and read data from another address. That's the same-origin policy. It's there so a random tab you opened can't quietly call your bank's API using cookies your browser is holding.
CORS — Cross-Origin Resource Sharing — is the official way to relax that rule. It's the API's way of saying: "this specific other address is allowed to talk to me." The important part is who enforces it.
CORS is not a lock on the server. It's the browser checking whether the server said yes.
The server just adds some headers. The browser reads them and decides whether to hand the response back to your JavaScript or block it.
What the preflight actually is
Here's the part that confuses people. For certain requests, the browser doesn't send your real request first. It sends a small, automatic question ahead of it, using the OPTIONS method.
Picture a kid walking up to a friend's front door. Before barging in, they ask the parent at the door: "Can I come in, and can I bring my backpack and my football?" If the parent says yes, they walk in. If not, they don't.
That question is the preflight. The browser sends it on its own — your code never writes it and never sees it. It looks roughly like this:
OPTIONS /features HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization, x-request-id
The server, if it agrees, answers with the matching permission headers:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: authorization, x-request-id
Access-Control-Max-Age: 600
Only if that answer approves the method and the headers does the browser send the real GET. If it doesn't, the request is blocked and you get the classic red CORS error in the console — even though the server may have been perfectly reachable.
So those two rows, OPTIONS then GET, are one call from your app's point of view. They come as a pair.
Simple requests skip the whole thing
Not every cross-origin call triggers a preflight. The browser has a category of simple requests that are allowed to go straight through, no questions asked. Roughly, a request stays simple when:
- the method is
GET,HEAD, orPOST, - it only carries a short list of "safe" headers,
- and it doesn't set anything unusual like
Authorization, a customContent-Type: application/json, or your ownx-*headers.
The moment you add one header outside that safe list, the request becomes non-simple, and the browser inserts a preflight before it. That single line — one custom header — is usually the whole reason the OPTIONS row appeared.
This is worth internalizing, because in a real app you rarely control it directly. It's a side effect of everything else you've wired up.
Where this bites in a real Angular app
I ran into this with an Angular app running inside an iframe. Every call it made showed up as an OPTIONS + GET pair, and someone was convinced the app was firing double requests.
It wasn't. The app uses HttpClient, and its interceptors quietly attach headers to every outgoing request — an Authorization token, an x-request-id for tracing, a ga-header for analytics. Useful headers, all of them. But every one of them is "non-simple."
So the chain is straightforward:
- Interceptor adds
Authorization,x-request-id,ga-header. - The request is now non-simple.
- The browser preflights it with
OPTIONS. - On approval, the real
GETgoes out.
Nothing is broken. The interceptors are doing exactly what they were built to do, and the browser is reacting exactly as the spec says.
The interesting comparison sat right next to it. A different call, made by the surrounding AEM page with a plain fetch and no custom headers, stayed a simple request. No preflight. One row in the network tab. Same server, same kind of GET — the only difference was the headers each side chose to send.
The detail that saves you the repeat
If you watch closely, sometimes you see an OPTIONS before every single GET, and sometimes you see it once and then a run of clean GETs. That difference is one header on the server's response: Access-Control-Max-Age.
It tells the browser how long it may cache the preflight answer. Set it to 600, and for ten minutes the browser trusts the earlier yes and skips asking again for the same method-and-header combination. Leave it at 0 or omit it, and the browser re-asks before each call.
An OPTIONS before every request isn't always a misconfiguration, but it's often a sign the Max-Age is missing or zero — cheap, invisible overhead you can remove with one header on the backend.
The takeaway
None of this is exotic once you name it. The preflight is the browser asking permission on your behalf before it lets one origin talk to another. The pair of rows is one call. The trigger is almost always a header you added somewhere upstream — often in an interceptor you forgot was even running.
When a CORS error shows up, the useful question isn't "why is CORS broken." It's "which header made this request non-simple, and did the server agree to it." That reframing tends to point straight at the fix.


Top comments (0)