ESC

Type to search the knowledge base.

Preflight OPTIONS Requests

When browsers send CORS OPTIONS preflights, which headers to return, caching Max-Age, and SPA debug tips.

intermediate3 min read
  • browser
  • cors
  • preflight
  • options
  • fetch

A CORS preflight is an automatic OPTIONS request the browser sends before certain cross-origin calls to ask the server, “are you OK with this method and these headers?” Your React code never writes it; the browser does. Failed preflights surface as opaque CORS errors even though the “real” POST never runs.

Docs: MDN Preflight, Fetch CORS protocol, CORS explained.

When preflight happens

Rough triggers for a non-simple request:

  • Methods other than GET / HEAD / POST (e.g. PUT, PATCH, DELETE)
  • Content-Type other than simple values (notably application/json)
  • Custom headers (Authorization, X-Request-Id, …)
  • Other non-safelisted bits (e.g. some ReadableStream bodies)
// Typically triggers preflight cross-origin
await fetch('https://api.example.com/v1/items', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({ name: 'x' }),
});

What the browser sends

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

What the server must answer

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 7200
Vary: Origin

With cookies / credentialed mode:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

Never combine credentials with Allow-Origin: *.

Max-Age

Access-Control-Max-Age caches the preflight result (browser-dependent caps). Higher values reduce OPTIONS chatter in apps that fire many JSON APIs. During CORS debugging, temporary low max-age avoids “I fixed the server but browser still fails” confusion — hard-refresh / clear cache.

Frontend vs backend ownership

Layer Responsibility
Browser Sends OPTIONS when required
API / gateway Answers allow-origin/methods/headers
SPA Avoid unnecessary custom headers; fix only if you control API
Dev proxy Hides cross-origin in local dev only

Adding random headers “for debugging” (X-Debug: 1) forces preflights. Batch intentional headers.

Debugging checklist

  1. Network panel: is OPTIONS red? Read its status and ACAO headers.
  2. OPTIONS 404/405 → gateway doesn’t handle OPTIONS for that route.
  3. OPTIONS 204 but POST still fails → real response missing ACAO.
  4. Works in Postman → expected; Postman is not a browser.

Interview out-loud

“Preflight is an OPTIONS request for non-simple CORS calls like JSON POST with Authorization. The server must allow origin, method, and headers; Max-Age caches the result. Frontend rarely fixes this without API changes.”

Gateway config sketch

Ensure API gateways answer OPTIONS on the same path as POST, with 204/200 and no auth challenge that blocks preflight (some WAFs require exceptions for OPTIONS). Log preflight failures separately — they never hit your controller logic, so app logs look “empty” while browsers fail.

Further depth

Teams often under-invest in this topic until an incident or CWV regression. Schedule a one-hour drill: reproduce the failure mode in DevTools, list the top three mitigations for your stack, and file tickets with owners. Revisit after the next major feature that touches networking, rendering, auth, or third parties — those are the moments regressions land. Keep primary documentation links in the runbook so on-call is not searching chat history at 2am.

Concrete artifacts to leave behind: a short architecture note, a CI assertion or header snapshot, and a dashboard panel (lab or field) that would have caught the last bug. Teaching the rest of the team the mental model matters as much as the one-line fix.

Further reading

Related guides