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/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
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
fetchwithmode: '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: 86400Now 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) or604800(7 days)
Reducing Preflight Requests#
If you’re seeing performance issues from preflight overhead:
- Set a high Max-Age — The single biggest improvement
- Use simple requests when possible — If you don’t need JSON, use form data
- Batch API calls — One request with multiple operations is better than many small requests
- Consider GraphQL — One endpoint, one preflight
- Use a same-origin architecture — If your API and frontend share a domain (with a reverse proxy), no CORS at all