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 GETNo-
HEADNo-
POST, Content-Type: text/plainNo-
POST, text/plain; charset=utf-8No-
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/jsonYesmethod POST, headers content-type
POST a Blob of type application/json, no header setYesmethod POST, headers content-type
GET with X-Custom: 1Yesheaders x-custom
GET with Authorization: Bearer ...Yesheaders authorization
GET with Accept: application/jsonNo-
GET with Range: bytes=0-10No-
GET with Range: bytes=0-10,20-30Yesheaders range
PUTYesmethod PUT
DELETEYesmethod DELETE
PATCHYesmethod PATCH
GET with credentials: 'include'No- (cookie sent on the GET)
PUT with credentials: 'include'Yesmethod PUT (no cookie on OPTIONS)
POST with a ReadableStream body (duplex: 'half')Yesmethod POST
XHR POST with an xhr.upload.onprogress listenerYesmethod POST
Same XHR POST, no upload listenerNo-
Same-origin PUT with JSON and X-CustomNo- (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

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 answerResult
204 + allow headersReal request sent
200 + allow headersReal request sent
404 + allow headersBlocked: "Response to preflight request doesn't pass access control check: It does not have HTTP ok status."
500 + allow headersBlocked, same message
404, no CORS headersBlocked: "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-AgePauseSecond call preflighted?
not set4.0 sNo (cached)
not set4.8 sNo (cached)
not set5.3 sYes
not set6.0 sYes
00.2 sYes, every time
-10.2 sYes
109 sNo (cached)
1011 sYes
608 s totalNo (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:

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

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.