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',
  }
});
```text


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

```javascript
// 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
```text


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

```javascript
// 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' })
});
```text


**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