TL;DR / Browsers automatically send an OPTIONS request before cross-origin requests that use custom headers, non-simple methods, or certain content types, and block the actual request if the server doesn't explicitly allow it.
How It Works
1 OPTIONS
┌───────────────────────────────────────────────┐
│ │
│ ↓
┌──────────────┐ ┌──────────────┐
│ Browser │ │ Server │
│ (origin A) │ │ (origin B) │
│ │ │ │
└──────────────┘ └──────────────┘
│ │
│ 2 Allow-* hdrs │
│───────────────────────────────────────────────┘
│
cache max-age
│
│
↓
┌──────────────┐ ┌──────────────┐
│ Preflight │ 3 Actual request │ Process │
│ Cache │───────────────────────────────→│ request │
│ │ │ │
└──────────────┘ └──────────────┘
The Same-Origin Policy blocks JavaScript from reading responses to cross-origin requests by default. CORS (Cross-Origin Resource Sharing) is the mechanism that relaxes this restriction through HTTP headers. The preflight is the most misunderstood part of CORS because it introduces an entirely separate request that developers do not explicitly trigger.
When Preflight Happens
Not every cross-origin request triggers a preflight. Simple requests -- those using GET, HEAD, or POST with standard content types (text/plain, multipart/form-data, application/x-www-form-urlencoded) and only CORS-safelisted headers -- skip the preflight entirely. The browser sends them directly and checks the response headers afterward.
A preflight is triggered when any of these conditions are met: the request uses a method other than GET/HEAD/POST (such as PUT, DELETE, PATCH), the Content-Type is application/json, or custom headers like Authorization or X-Request-ID are included. This covers the vast majority of API calls from modern SPAs.
The OPTIONS Exchange
The preflight is an HTTP OPTIONS request sent to the exact same URL as the intended request. The browser attaches Origin, Access-Control-Request-Method, and Access-Control-Request-Headers headers describing what the real request will look like. The server must respond with the corresponding Access-Control-Allow-* headers. If the server does not include the correct headers, or responds with a non-2xx status, the browser blocks the actual request entirely. No JavaScript error handler receives the response body -- the request simply fails.
The critical response headers are Access-Control-Allow-Origin (must match the requesting origin or be *), Access-Control-Allow-Methods (must include the requested method), and Access-Control-Allow-Headers (must list every custom header). Omitting even one requested header from Allow-Headers kills the request.
Caching With max-age
Every preflight adds latency -- it's a full round trip before the real request starts. The Access-Control-Max-Age header tells the browser how long to cache the preflight response in seconds. During that window, subsequent requests to the same URL with the same method and headers skip the OPTIONS request. Chrome caps this at 7200 seconds (2 hours), Firefox at 86400 (24 hours). Without max-age, some browsers cache for only 5 seconds, or not at all.
Credentials and Wildcards
When the request includes credentials (cookies, HTTP auth), the server cannot use * for Access-Control-Allow-Origin. It must echo the exact origin. Additionally, Access-Control-Allow-Credentials: true must be present. This is where many CORS configurations break -- developers set Allow-Origin: * during development, then wonder why authenticated requests fail.
Server-Side Considerations
The preflight is handled before your application logic runs. If your web server, reverse proxy, or API gateway does not handle OPTIONS requests for the target routes, you get a 404 or 405 on the preflight, and the actual request never fires. Many frameworks have CORS middleware that intercepts OPTIONS and responds automatically, but it must be configured for every route that receives cross-origin requests, including error-handling routes.
Preflight also interacts with HTTP caching layers. CDNs and reverse proxies that cache by URL without considering the Origin header can serve a preflight response for origin A to origin B, breaking CORS for the second origin. The Vary: Origin header is essential when dynamically reflecting the origin.
Gotchas
- Preflight does not happen for simple GET/POST requests with standard headers. You can have CORS failures without ever seeing an OPTIONS request -- the browser just blocks the response.
- Missing
Access-Control-Max-Agemeans the browser may preflight every single request, doubling latency on API-heavy pages. Allow-Origin: *with credentials is invalid. The browser rejects it silently. You must echo the specific origin and setVary: Origin.- Server returning 301/302 on OPTIONS causes preflight failure. Ensure redirects do not apply to OPTIONS requests, especially trailing-slash redirects.
- The error is invisible to JavaScript.
fetchcatches aTypeErrorwith no details. The actual CORS error is only visible in browser DevTools console.