CORS Preflight Requests: What They Are and Why Your API Needs to Handle Them

Every time your React app sends a JSON POST request, the browser does something you might not expect: it sends TWO requests instead of one. The first is an OPTIONS “preflight” request. The second is your actual request.

This confuses a lot of people. Why is the browser sending extra requests? Why is my API getting OPTIONS requests I never wrote endpoints for? Why does Postman work but the browser doesn’t?

Let me explain.

What Triggers a Preflight#

A preflight is triggered when your request is NOT a “simple request.” Here are the specific conditions:

Triggers Preflight: Custom Headers#

fetch('https://api.example.com/data', {
  headers: {
    'Authorization': 'Bearer token123',
  }
});

Any header that’s not on the CORS-safelist triggers preflight. The safelist includes: Accept, Accept-Language, Content-Language, Content-Type (but only with certain values).

Triggers Preflight: Content-Type: application/json#

// This triggers preflight:
fetch('https://api.example.com/data', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'John' })
});

// This does NOT trigger preflight:
fetch('https://api.example.com/data', {
  method: 'POST',
  headers: { 'Content-Type': 'text/plain' },
  body: 'name=John'
});

Yeah, application/json triggers preflight but text/plain doesn’t. This is because JSON parsing is more complex and historically more dangerous than plain text.

The three “safe” Content-Type values that don’t trigger preflight:

  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded

Everything else (including application/json) triggers preflight.

Triggers Preflight: Non-Simple Methods#

fetch('https://api.example.com/data', { method: 'PUT' });   // Preflight
fetch('https://api.example.com/data', { method: 'DELETE' }); // Preflight
fetch('https://api.example.com/data', { method: 'PATCH' });  // Preflight

GET, HEAD, and POST (with safe content-type) don’t trigger preflight.

Triggers Preflight: Advanced Features#

  • XMLHttpRequest.upload (for upload progress)
  • EventSource with cross-origin
  • fetch with mode: 'cors' (default) and any of the above conditions
  • WebSocket connections

Does NOT Trigger Preflight#

// Simple GET
fetch('https://api.example.com/data');

// Simple POST with form data
fetch('https://api.example.com/data', {
  method: 'POST',
  body: new FormData(formElement)
});

// Loading an image
new Image().src = 'https://example.com/photo.jpg';

// Loading a script
const script = document.createElement('script');
script.src = 'https://example.com/script.js';

These are “simple requests” — they go directly to the server with just an Origin header, no preflight needed.

How Preflight Works (Step by Step)#

Let’s say your app sends this:

fetch('https://api.example.com/users/123', {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer abc123'
  },
  body: JSON.stringify({ name: 'Jane' })
});

Step 1: Browser sends preflight

OPTIONS /users/123 HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization

“Hey server, I want to send a PUT request with JSON content and an Authorization header. Is that okay?”

Step 2: Server responds

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

“Sure, here’s what I allow.”

Step 3: Browser sends actual request

PUT /users/123 HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
Content-Type: application/json
Authorization: Bearer abc123

{"name": "Jane"}

Step 4: Server responds

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com
Content-Type: application/json

{"id": 123, "name": "Jane"}

If step 2 fails (wrong headers, wrong origin, no response), the browser never sends step 3. That’s the CORS error you see.

Caching Preflight Results#

Without caching, every API call triggers a preflight. If you’re making 50 API calls on page load, that’s 50 extra OPTIONS requests.

Access-Control-Max-Age tells the browser to cache the preflight result:

Access-Control-Max-Age: 86400

Now the browser only sends one OPTIONS request per 24 hours per URL pattern. The remaining 49 requests go straight through.

Recommended values:

  • Development: 0 (always preflight, easier to debug)
  • Staging: 3600 (1 hour)
  • Production: 86400 (24 hours) or 604800 (7 days)

Reducing Preflight Requests#

If you’re seeing performance issues from preflight overhead:

  1. Set a high Max-Age — The single biggest improvement
  2. Use simple requests when possible — If you don’t need JSON, use form data
  3. Batch API calls — One request with multiple operations is better than many small requests
  4. Consider GraphQL — One endpoint, one preflight
  5. Use a same-origin architecture — If your API and frontend share a domain (with a reverse proxy), no CORS at all

Verify Your CORS#