CORS preflight request (OPTIONS), tested
A preflight request is an OPTIONS request the browser sends before a cross-origin request that is not "simple". It asks the server: may this origin use this method and these headers? Only if the answer is yes does the real request go out. We ran 23 kinds of fetch and XHR calls in Chromium 153 and counted what reached the server.
1. Our test setup
The page was served from http://localhost:8992. The API ran on http://localhost:8993. Different port means a different origin, so every call was cross-origin. A cookie sid=abc was set for localhost.
The API logged method, Access-Control-Request-* headers and Cookie for each request. Its OPTIONS answer allowed everything: it echoed the Origin, set Access-Control-Allow-Credentials: true, allowed GET, POST, PUT, DELETE, PATCH and echoed the requested headers. So every request below succeeded, and we only counted preflights.
2. Which requests trigger a preflight
| Request (cross-origin) | Preflight? | OPTIONS asked for |
|---|---|---|
fetch(u) plain GET | No | - |
| HEAD | No | - |
POST, Content-Type: text/plain | No | - |
POST, text/plain; charset=utf-8 | No | - |
POST with a string body (browser sets text/plain;charset=UTF-8) | No | - |
POST URLSearchParams (application/x-www-form-urlencoded) | No | - |
POST FormData (multipart/form-data) | No | - |
POST, Content-Type: application/json | Yes | method POST, headers content-type |
POST a Blob of type application/json, no header set | Yes | method POST, headers content-type |
GET with X-Custom: 1 | Yes | headers x-custom |
GET with Authorization: Bearer ... | Yes | headers authorization |
GET with Accept: application/json | No | - |
GET with Range: bytes=0-10 | No | - |
GET with Range: bytes=0-10,20-30 | Yes | headers range |
| PUT | Yes | method PUT |
| DELETE | Yes | method DELETE |
| PATCH | Yes | method PATCH |
GET with credentials: 'include' | No | - (cookie sent on the GET) |
PUT with credentials: 'include' | Yes | method PUT (no cookie on OPTIONS) |
POST with a ReadableStream body (duplex: 'half') | Yes | method POST |
XHR POST with an xhr.upload.onprogress listener | Yes | method POST |
| Same XHR POST, no upload listener | No | - |
Same-origin PUT with JSON and X-Custom | No | - (same origin never preflights) |
The rule that fits every row: a request is "simple" only if the method is GET, HEAD or POST, every header you add is on the safelist (Accept, Accept-Language, Content-Language, Content-Type and a single-range Range), and Content-Type is one of text/plain, application/x-www-form-urlencoded or multipart/form-data. Anything else is preflighted. Credentials alone do not cause a preflight.
Two surprises. A JSON Blob triggers a preflight even if you never set a header, because the browser copies the Blob type into Content-Type. And an XHR upload progress listener alone turns a simple POST into a preflighted one.
The streaming body did get its preflight, but then the request failed with TypeError: Failed to fetch and the console showed net::ERR_ALPN_NEGOTIATION_FAILED. Chromium only sends streaming uploads over HTTP/2 or later, and our test API spoke plain HTTP/1.1.
3. What the OPTIONS request carries
OPTIONS /api HTTP/1.1
Origin: http://localhost:8992
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization,content-type,x-alpha,x-zeta
Accept: */*
Sec-Fetch-Mode: cors
Access-Control-Request-Methodwas always there, even for POST.Access-Control-Request-Headerswas only there when there were non-safelisted headers. Chromium sent the names lowercase, sorted, comma-separated with no spaces. We setX-Zeta,Content-Type,X-Alpha,Authorizationin that order and gotauthorization,content-type,x-alpha,x-zeta.- No cookies, ever. Even with
credentials: 'include', the OPTIONS had noCookieheader. The real PUT that followed hadsid=abc. So never put auth checks on OPTIONS. A preflight with anAuthorizationheader does not carry the token either. It only lists the header name. - No request body.
The Origin header is explained in Origin header. Sec-Fetch-Mode: cors is covered in Sec-Fetch-Site, Mode and Dest.
4. What the server must answer
The preflight answer needs a 2xx status and the right Access-Control-Allow-* headers. We tried other statuses with the same headers:
| OPTIONS answer | Result |
|---|---|
| 204 + allow headers | Real request sent |
| 200 + allow headers | Real request sent |
| 404 + allow headers | Blocked: "Response to preflight request doesn't pass access control check: It does not have HTTP ok status." |
| 500 + allow headers | Blocked, same message |
| 404, no CORS headers | Blocked: "Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource." |
In every blocked case the real request never reached the server, and fetch threw TypeError: Failed to fetch. The full text is in the console. All CORS console messages are listed in CORS error messages explained.
A minimal Node handler:
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': req.headers.origin, // check it against a list first
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '600',
'Vary': 'Origin',
});
return res.end();
}
Never echo any origin blindly. See CORS: allow multiple origins for an allowlist. The header details are in Access-Control-Allow-Methods, Access-Control-Allow-Headers and Access-Control-Allow-Credentials.
5. Preflight caching and Access-Control-Max-Age
We sent the same JSON PUT twice to the same URL, with a pause between, and counted OPTIONS hits.
| Access-Control-Max-Age | Pause | Second call preflighted? |
|---|---|---|
| not set | 4.0 s | No (cached) |
| not set | 4.8 s | No (cached) |
| not set | 5.3 s | Yes |
| not set | 6.0 s | Yes |
| 0 | 0.2 s | Yes, every time |
| -1 | 0.2 s | Yes |
| 10 | 9 s | No (cached) |
| 10 | 11 s | Yes |
| 60 | 8 s total | No (cached) |
So without the header Chromium caches a preflight for 5 seconds. A set value is honoured. 0 disables the cache. Per the Chromium source and MDN docs (not measured, it would take hours), Chromium caps the value at 7200 seconds (2 hours). Firefox's cap is 86400 per its docs.
The cache is narrow. With Max-Age 60 we saw:
- Same URL again: no preflight.
- Different path (
/c-y): new preflight. - Same path with a query string (
/c-x?q=1): new preflight. The cache key is the full URL. - Same URL, a header not in the cached list (
X-BafterX-A): new preflight.
For APIs with many URLs (/items/1, /items/2...) the cache helps little. Each new URL costs one extra round trip. More on this in Access-Control-Max-Age.
6. How to avoid a preflight
- Serve the API from the same origin, for example behind a
/api/path on the same host. Same-origin requests never preflight (we saw a same-origin PUT with JSON and a custom header go straight through). - For simple reads, use plain GET with no custom headers. Put tokens in a cookie instead of an
Authorizationheader if your design allows it (see SameSite cookies). - Sending JSON as
text/plainavoids the preflight, but only do it if the server parses it on purpose. It also skips the protection that a preflight gives against cross-site form-style POSTs, so the server must checkOriginor a CSRF token. - Otherwise, set
Access-Control-Max-Ageso repeat calls to the same URL skip the OPTIONS.
Check what your OPTIONS endpoint really returns with the HTTP headers checker or the PlainHeaders extension.
Frequently asked questions
Why does my GET request send an OPTIONS first?
Because it is cross-origin and has a non-safelisted header, usually Authorization or a custom X- header. In Chromium 153 a plain GET had no preflight, but GET with Authorization or X-Custom did. Accept: application/json alone did not.
Does Content-Type: application/json trigger a preflight?
Yes. Only text/plain, application/x-www-form-urlencoded and multipart/form-data are simple. A JSON POST was preflighted in our test, even when the type came from a Blob and no header was set by hand.
Are cookies sent with the preflight?
No. Even with credentials: 'include', Chromium sent the OPTIONS without a Cookie header. The real request after it carried the cookie. Do not require auth on OPTIONS.
How long does the browser cache a preflight?
Without Access-Control-Max-Age, 5 seconds in Chromium 153 (cached at 4.8 s, re-sent at 5.3 s). A set value is used as given; 0 turns caching off. Chromium caps it at 7200 s per its source, not measured. The cache is per full URL.
What status should the OPTIONS response have?
Any 2xx. 200 and 204 both worked. 404 and 500 with correct CORS headers were blocked with "It does not have HTTP ok status."
Written with AI assistance. Measured on 2026-10-02 with headless Chromium 153.0.8010.12 (Playwright) and Node.js 24. The page ran on http://localhost:8992 and the API on http://localhost:8993. A Node server logged every request, so each OPTIONS hit was counted server-side. Items marked as from the docs were not measured. PlainHeaders is our extension; Chromium, Playwright and Node.js are free.