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/plainmultipart/form-dataapplication/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