Preflight Requests
If simple requests are the express lane, preflight requests are the full security checkpoint. Before your actual request goes anywhere, the browser sends a separate, preliminary request to ask the server: "Hey, would you be okay with receiving a request that looks like this?" Only if the server says yes does the browser proceed with the real thing.
This is the mechanism that confuses the most people, generates the most Stack Overflow questions, and causes the most late-night debugging sessions. Let's make sure you never have to be one of those people again.
Why Preflights Exist
Here's the historical context that makes the whole thing make sense.
Before CORS existed, servers lived in a simpler world. A server that exposed DELETE /api/users/42 only expected that request to come from trusted sources — admin panels, internal tools, scripts with proper authentication. The server could reasonably assume that browsers would never send a cross-origin DELETE request, because browsers couldn't. The Same-Origin Policy prevented it.
Then CORS came along and said: "Actually, we'd like to let JavaScript make cross-origin requests now."
The problem: if browsers just started sending DELETE requests to every server, they'd be executing potentially destructive operations on servers that never anticipated cross-origin requests. These legacy servers had no CORS configuration because CORS didn't exist when they were built. They were relying on the Same-Origin Policy as an implicit security layer.
The preflight is the solution. Before sending a "dangerous" request (anything beyond what an HTML form could do), the browser asks permission first. A server that has never heard of CORS will either:
- Return an error for the OPTIONS request (404, 405, 500)
- Return a 200 but without any
Access-Control-Allow-*headers
Either way, the preflight fails, and the browser never sends the actual DELETE. The legacy server is protected, not because it did anything clever, but because the preflight protocol was designed to be backward-compatible with servers that know nothing about CORS.
This is genuinely elegant engineering, even if it doesn't feel that way when you're staring at a preflight failure at 11 PM.
What Triggers a Preflight
A preflight is sent when the request is not a "simple request" — that is, when it fails any of the three conditions from the previous chapter. Here's the complete trigger list:
Non-Simple Methods
Any HTTP method other than GET, HEAD, or POST:
// All of these trigger a preflight
fetch(url, { method: 'PUT' });
fetch(url, { method: 'DELETE' });
fetch(url, { method: 'PATCH' });
Non-Safelisted Headers
Any header not on the CORS-safelisted list:
// These all trigger a preflight
fetch(url, {
headers: {
'Authorization': 'Bearer token123', // not safelisted
'X-Request-ID': 'abc-123', // custom header
'Cache-Control': 'no-cache', // not safelisted
}
});
Even a single non-safelisted header is enough to trigger the preflight, regardless of the method.
Non-Simple Content-Type
Any Content-Type value other than the big three:
// This triggers a preflight even though the method is POST
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // not safelisted
body: JSON.stringify({ key: 'value' })
});
To really drive this home, here's a table:
| Request | Preflight? | Why? |
|---|---|---|
GET with no custom headers | No | All three conditions met |
POST with Content-Type: application/x-www-form-urlencoded | No | All three conditions met |
POST with Content-Type: application/json | Yes | Content-Type not safelisted |
GET with Authorization header | Yes | Header not safelisted |
PUT with no custom headers | Yes | Method not safelisted |
DELETE with Content-Type: text/plain | Yes | Method not safelisted |
POST with X-Custom-Header: foo | Yes | Header not safelisted |
The Complete Preflight Flow
Let's trace the full lifecycle. We have:
- A page at
https://app.example.com - JavaScript making a PUT request with JSON to
https://api.example.com/users/42
The JavaScript
fetch('https://api.example.com/users/42', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWxpY2UifQ.abc123'
},
body: JSON.stringify({ name: 'Alice Updated' })
});
This triggers a preflight for three separate reasons: the method is PUT, the Content-Type is application/json, and there's an Authorization header. One reason would have been enough, but we're thorough.
Step 1: The Browser Sends the Preflight (OPTIONS)
Before sending the PUT, the browser sends:
OPTIONS /users/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
Connection: keep-alive
Accept: */*
Let's break this down header by header:
OPTIONS— The HTTP method. This is not your PUT request. This is the browser asking about your PUT request.Origin— Same as always: where the page lives.Access-Control-Request-Method: PUT— "I want to send a PUT request. Is that okay?"Access-Control-Request-Headers: authorization, content-type— "I want to include these headers. Are those okay?" Note that the values are lowercased and comma-separated.
Notice what's not in the preflight: there's no request body. There's no Authorization header. The preflight is a metadata query — it's asking about the request, not sending it.
You can simulate this preflight with curl:
curl -v -X OPTIONS https://api.example.com/users/42 \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: authorization, content-type"
Step 2: The Server Responds to the Preflight
If the server is properly configured, it responds:
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: Authorization, Content-Type, X-Request-ID
Access-Control-Max-Age: 86400
Vary: Origin
Breaking this down:
204 No Content— Standard "success, nothing to show" status. More on the 204-vs-200 debate in the next chapter.Access-Control-Allow-Origin: https://app.example.com— "Yes, that origin is allowed."Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS— "These are the methods I'll accept from cross-origin requests."Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-ID— "These are the headers I'll accept."Access-Control-Max-Age: 86400— "Cache this preflight result for 86400 seconds (24 hours). Don't ask me again until then." (Chapter 9 goes deep on caching.)Vary: Origin— "This response depends on the Origin header, so caches should key on it."
Step 3: The Browser Validates the Preflight Response
The browser checks:
- Does
Access-Control-Allow-Originmatch the page's origin? ✅ (https://app.example.com) - Is
PUTlisted inAccess-Control-Allow-Methods? ✅ - Are
authorizationandcontent-typelisted inAccess-Control-Allow-Headers? ✅ (comparison is case-insensitive)
All checks pass. The preflight succeeded.
Step 4: The Browser Sends the Actual Request
Now — and only now — the browser sends the real PUT request:
PUT /users/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWxpY2UifQ.abc123
Content-Type: application/json
Content-Length: 27
{"name": "Alice Updated"}
This is the actual request with the actual body and the actual headers. The Origin header is still present — CORS headers appear on both the preflight and the actual request.
Step 5: The Server Responds to the Actual Request
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Content-Length: 52
{"id": 42, "name": "Alice Updated", "version": 3}
The actual response also includes Access-Control-Allow-Origin. The browser checks this again — even after a successful preflight, the actual response must also grant permission. The preflight confirms that the server accepts the request; the CORS header on the actual response confirms that the server wants the browser to expose the response to JavaScript.
The Full Trace, Visually
Browser Server
│ │
│ OPTIONS /users/42 │
│ Origin: https://app.example.com │
│ Access-Control-Request-Method: │
│ PUT │
│ Access-Control-Request-Headers: │
│ authorization, content-type │
│──────────────────────────────────>│
│ │
│ 204 No Content │
│ Access-Control-Allow-Origin: │
│ https://app.example.com │
│ Access-Control-Allow-Methods: │
│ GET, POST, PUT, DELETE, OPTIONS │
│ Access-Control-Allow-Headers: │
│ Authorization, Content-Type │
│ Access-Control-Max-Age: 86400 │
│<──────────────────────────────────│
│ │
│ PUT /users/42 │
│ Origin: https://app.example.com │
│ Authorization: Bearer eyJ... │
│ Content-Type: application/json │
│ {"name": "Alice Updated"} │
│──────────────────────────────────>│
│ │
│ 200 OK │
│ Access-Control-Allow-Origin: │
│ https://app.example.com │
│ {"id": 42, "name": "..."} │
│<──────────────────────────────────│
│ │
Two round trips. The OPTIONS, then the PUT. This is the cost of a preflight.
What Happens When the Preflight Fails
Let's say the server isn't configured for CORS at all. The preflight comes in, and the server responds:
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 0
No Access-Control-Allow-Origin. No Access-Control-Allow-Methods. The browser's verdict: preflight failed. The actual PUT request is never sent.
This is the fundamental difference from a simple request failure:
| Simple Request | Preflight Failure | |
|---|---|---|
| Request sent? | Yes | Only the OPTIONS |
| Server processes request? | Yes | Only the OPTIONS |
| Side effects? | Possible | None from the actual request |
| Response visible to JS? | No | No |
When a preflight fails, your console shows:
Access to fetch at 'https://api.example.com/users/42' from origin
'https://app.example.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the
requested resource.
Note the key phrase: "Response to preflight request." That tells you it was the OPTIONS that failed, not your actual request. Your PUT never left the browser.
Partial Preflight Failures
Sometimes the preflight partially succeeds. The server might allow the origin but not the method:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Content-Type
The browser checks: "Is PUT in the allowed methods? No." Preflight fails. Error message:
Access to fetch at 'https://api.example.com/users/42' from origin
'https://app.example.com' has been blocked by CORS policy:
Method PUT is not allowed by Access-Control-Allow-Methods in
preflight response.
Or the method is allowed but a header isn't:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type
"Is authorization in the allowed headers? No." Preflight fails:
Access to fetch at 'https://api.example.com/users/42' from origin
'https://app.example.com' has been blocked by CORS policy:
Request header field authorization is not allowed by
Access-Control-Allow-Headers in preflight response.
These error messages are actually quite specific and helpful once you know what to look for. The browser is telling you exactly which check failed.
The Classic Mistake: Handling GET but Not OPTIONS
This one deserves its own section because I see it approximately once a week.
You write an Express route:
app.put('/users/:id', authenticate, (req, res) => {
// Update the user
res.json({ id: req.params.id, name: req.body.name });
});
You test it with curl:
curl -X PUT https://api.example.com/users/42 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer mytoken" \
-d '{"name": "Alice Updated"}'
# Works perfectly! 200 OK with the response body.
You deploy. Your frontend team reports that the endpoint is broken. The CORS error says the preflight failed.
The problem: your Express app has a handler for PUT /users/:id, but no handler for OPTIONS /users/:id. When the browser's preflight arrives, Express either returns a 404 (if no route matches OPTIONS at all) or a 405 Method Not Allowed — neither of which includes the CORS headers the browser needs.
The fix is to ensure your server handles OPTIONS for every route that needs CORS. In Express, the cors middleware does this automatically:
const cors = require('cors');
app.use(cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type']
}));
This middleware intercepts OPTIONS requests and responds with the appropriate CORS headers before your route handlers ever see the request. This is also the subject of Chapter 6.
Performance Implications
Every preflight is an extra HTTP round trip. For a user on a 100ms latency connection, that's 100ms added to every API call (at minimum — the OPTIONS request also needs server processing time).
For a single-page app that makes 10 API calls on page load, all using Authorization headers, that's 10 preflights. On a slow connection, that's noticeable.
Mitigation: Access-Control-Max-Age
The Access-Control-Max-Age response header tells the browser to cache the preflight result:
Access-Control-Max-Age: 86400
After the first preflight to a given URL, the browser won't send another for 24 hours (for the same origin, method, and headers combination). This turns 10 preflights into 1 on the first load and 0 on subsequent loads.
Browser limits on Max-Age:
- Chrome: caps at 7200 seconds (2 hours), regardless of what the server says
- Firefox: caps at 86400 seconds (24 hours)
- Safari: caps at 604800 seconds (7 days)
So even if your server says Access-Control-Max-Age: 31536000 (one year), Chrome will still re-send the preflight after 2 hours. Set your Max-Age to something reasonable — 3600 (1 hour) or 86400 (24 hours) are common choices.
Mitigation: Reduce the Number of Unique URL Patterns
Preflight caching is per-URL. A preflight for GET /users/1 is cached separately from GET /users/2. In Chrome (as of recent versions), the cache is keyed on (origin, URL, request method, and each request header name) — so in practice, if all your API calls use the same set of headers, you get reasonable cache hit rates.
Mitigation: Use Simple Requests Where Possible
For truly public, read-only endpoints that don't need authentication, consider designing them to work as simple requests:
// No preflight: GET with no custom headers
fetch('https://api.example.com/public/data');
This isn't always possible — most APIs need authentication — but it's worth keeping in mind for public endpoints.
Seeing Preflights in DevTools
Open the Network tab in Chrome DevTools and trigger an API call that requires a preflight. You'll see two entries:
- An OPTIONS request to the same URL
- The actual request (PUT, DELETE, etc.) immediately after
If the preflight fails, you'll only see the OPTIONS request. The actual request never appears.
In Chrome, you can filter by method: type method:OPTIONS in the filter bar to see only preflights. This is especially useful when debugging a page that makes many API calls — you can quickly identify which calls are generating preflights and which are simple.
Firefox's DevTools show a small "preflight" badge on the OPTIONS request, making it easier to identify at a glance. Firefox also shows the CORS-related headers prominently in the response headers section.
# Quick debugging: simulate a preflight with curl
# If this returns the right Access-Control-* headers, your server is fine
curl -v -X OPTIONS https://api.example.com/users/42 \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: authorization, content-type" \
2>&1 | grep -i "access-control"
# Expected output:
# < Access-Control-Allow-Origin: https://app.example.com
# < Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
# < Access-Control-Allow-Headers: Authorization, Content-Type
# < Access-Control-Max-Age: 86400
If that curl command returns the expected headers, your server-side CORS configuration is correct and the problem is elsewhere (wrong origin, missing header in the allow list, etc.). If it returns no CORS headers, your server isn't handling the preflight, and it's time to look at your middleware configuration.
Summary
Preflights are the browser's way of asking permission before sending requests that couldn't have happened in the pre-CORS web. They add a round trip, but they protect legacy servers and give modern servers explicit control over what cross-origin requests they accept.
The sequence: OPTIONS with Access-Control-Request-* headers → server responds with Access-Control-Allow-* headers → browser sends the actual request. If any step fails, the actual request is never sent.
If you take one thing from this chapter: when your CORS error mentions "preflight," the problem is with your server's response to OPTIONS requests, not with your actual API endpoint. Fix the OPTIONS handler, and the rest will follow.