toolhq.io

All posts
5 min readby Jameel Haider

CORS preflight requests explained

You add a Content-Type: application/json header to a fetch call and suddenly your browser makes two requests instead of one, with the first being an OPTIONS you never wrote. That extra request is the CORS preflight, and it is the browser asking the server for permission before sending the real call. Knowing which requests trigger it saves a lot of debugging.

Simple requests skip the preflight

CORS divides cross-origin requests into two groups. A simple request is sent directly, and the browser only checks the response afterward. A request stays simple when all of these hold:

  • The method is GET, HEAD, or POST.
  • The only headers you set are from a short safelist (such as Accept, Content-Language, and Content-Type with a restricted value).
  • If Content-Type is present, it is application/x-www-form-urlencoded, multipart/form-data, or text/plain.

Break any of these and the request becomes non-simple.

What makes a request non-simple

The common triggers in modern apps are:

  • A method like PUT, PATCH, or DELETE.
  • A Content-Type: application/json body, which is most JSON APIs.
  • A custom header such as Authorization set manually, or X-Requested-With.

Any one of these causes the browser to send a preflight first. This is a security feature: the server gets a chance to approve the real request before it arrives, so a hostile page cannot quietly issue a cross-origin DELETE.

The preflight conversation

The preflight is an OPTIONS request carrying two request headers that describe the real call:

OPTIONS /api/items HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: content-type, authorization

The server answers with matching allow headers:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 600

If the method and every requested header are covered by the allow headers, the browser proceeds with the real request. If not, it blocks the call and logs a CORS error, which is why these failures never reach your server-side logs.

Max-Age caching

Sending an OPTIONS before every call would be wasteful, so Access-Control-Max-Age tells the browser how many seconds it may cache the preflight result for that method and header combination. With Max-Age: 600, repeated calls within ten minutes skip the preflight entirely. Browsers cap this value, so a very large number is silently reduced, but setting a sensible value still removes most of the overhead.

A frequent mistake is forgetting that credentials change the rules: when a request sends cookies, Access-Control-Allow-Origin cannot be * and must name the exact origin, and Access-Control-Allow-Credentials: true must be present.

Inspect the headers

When a cross-origin call fails, the fastest check is to look at the actual response headers. The HTTP header viewer fetches a URL and shows every header the server returns, so you can confirm whether the allow headers match what your request needs. Pair this with HTTP headers explained for the broader set and cookie flags when credentials are involved.

The practical takeaway: a preflight is normal, not an error. Make your server answer OPTIONS with the right allow headers, set a reasonable Max-Age, and the second request disappears from the common path.