Preflight vs Simple Request: The Performance Cost
You open the Network panel expecting one request and see two:
OPTIONS https://api.example.com/orders 204 (preflight)
POST https://api.example.com/orders 201
Every call to your API is doubling up — an OPTIONS immediately before each POST — and you want to know whether that extra line in the waterfall is worth eliminating, and how.
Root cause
The WHATWG Fetch Standard (§4.1, “Preflight requests”) classifies a cross-origin request as either simple — sent directly — or non-simple, which must be preceded by a CORS-preflight OPTIONS. A request stays simple only if its method is GET, HEAD, or POST; it carries only CORS-safelisted request headers; and, for a POST, its Content-Type is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain. The moment you add Content-Type: application/json, a custom header like X-Request-ID, or use a method like PUT or DELETE, the request becomes non-simple and the browser inserts the extra round-trip. The OPTIONS you see is not a bug — it is the spec-mandated permission probe for a non-simple request.
This page is part of Preflight Performance Analysis, which quantifies the preflight round-trip and how caching amortizes it.
Prerequisite state
Before restructuring anything, confirm:
- The request really is cross-origin (different scheme, host, or port). Same-origin requests never preflight.
- You know exactly what makes it non-simple — the method, a specific header, or the
Content-Type. Check the actual request headers in DevTools, not your source code. - The endpoint’s server can accept an alternative
Content-Typeif you intend to change it. Do not change the wire format without confirming the server parses it.
The performance delta
The cost difference is the entire preflight round-trip — but only on requests the cache does not already cover.
| Aspect | Simple request | Preflighted (non-simple) request |
|---|---|---|
| Network requests | 1 (actual only) | 2 (OPTIONS then actual) |
| First-call latency | 1 RTT | 2 RTT (preflight + actual) |
| Cached subsequent calls | 1 RTT | 1 RTT (preflight served from cache) |
| Triggers | GET/HEAD/POST + safelisted headers + safelisted Content-Type |
Non-safe method, custom header, or non-safelisted Content-Type |
Typical Content-Type |
text/plain, application/x-www-form-urlencoded, multipart/form-data |
application/json |
| Best for | One-off calls, cold caches, high-RTT clients | Anything cacheable under Access-Control-Max-Age |
The takeaway: a preflight hurts most on the first cross-origin call and on requests whose cache key keeps changing. If a request is made once (a beacon, a one-shot form post) or the client is on a high-RTT link, keeping it simple removes a full round-trip that caching could never recover.
Step-by-step: eliminate an unnecessary preflight
Step 1 — Confirm what triggers it
In DevTools, click the OPTIONS entry and read Access-Control-Request-Method and Access-Control-Request-Headers in its request headers. Those two values name exactly what the browser is asking permission for — the non-safe method and/or the non-safelisted headers.
Step 2 — Send a form-encoded body instead of JSON
If the payload is flat, application/x-www-form-urlencoded is safelisted and keeps a POST simple:
// Non-simple: application/json forces a preflight.
await fetch('https://api.example.com/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: 'A-100', qty: 2 }),
});
// Simple: urlencoded Content-Type is safelisted, no preflight.
await fetch('https://api.example.com/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ sku: 'A-100', qty: '2' }),
});
The server must parse the urlencoded body (for example, Express express.urlencoded() instead of express.json()).
Step 3 — Remove or relocate custom headers
A single custom header such as X-Request-ID forces a preflight. If it is non-essential, move it into the body or a query parameter:
// Instead of a custom header, carry the trace id in the body.
await fetch('https://api.example.com/orders?rid=abc-123', {
method: 'POST',
headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
body: JSON.stringify({ sku: 'A-100', qty: 2 }),
});
Note that text/plain is safelisted, so you can even keep a JSON string as the body as long as the server is willing to parse a text/plain payload. Do not do this for anything that must be authenticated — see the security note below.
Step 4 — Verify no OPTIONS is emitted
Reload and confirm the OPTIONS line is gone from the waterfall (verification section below).
Verification
# A simple request emits no preflight; the actual method returns directly.
curl -si -X POST https://api.example.com/orders \
-H "Origin: https://app.example.com" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "sku=A-100&qty=2" | grep -i "access-control-allow-origin\|HTTP/"
In DevTools → Network, trigger the request and confirm there is no OPTIONS entry preceding it. If the OPTIONS is gone, the request is now simple and you have removed one round-trip per call. To measure the saving precisely, follow Measuring CORS Preflight Latency in Production.
Security boundary note
Do not strip authentication or CSRF protection to save a round-trip. The most common “optimization” that backfires is dropping the Authorization header — which does eliminate the preflight, because Authorization is non-safelisted — and falling back to an implicit cookie or, worse, an unauthenticated endpoint. A saved RTT is worth a few milliseconds; a bypassed auth boundary is worth an incident. Keep the credential and instead cache the preflight with a sensible Access-Control-Max-Age per Cache Duration Tuning & Max-Age. Similarly, switching to text/plain to smuggle JSON past the preflight is fine for benign public data but must never be used to weaken server-side content-type validation on state-changing endpoints.
Common mistakes
| Mistake | What actually happens | Fix |
|---|---|---|
Removing Authorization to kill the preflight |
Eliminates the round-trip but drops the auth boundary; the endpoint becomes spoofable | Keep the header; cache the preflight with Access-Control-Max-Age instead |
Switching to text/plain but leaving strict JSON parsing on the server |
The now-simple request is rejected server-side with a 415 or parse error |
Update the server to accept the new Content-Type, or keep JSON and cache the preflight |
| Assuming every request benefits from staying simple | Once the preflight is cached, simple and non-simple cost the same on warm calls; the refactor buys nothing | Only chase simplicity for one-off, cold-cache, or high-RTT requests |
| Adding a custom header “just for tracing” on a hot path | Converts a simple request into a preflighted one, adding an RTT to every first call | Carry trace ids in the body or query string on safelisted requests |
FAQ
Is a simple request always faster than a preflighted one?
On the first, uncached call yes — a simple request saves the entire OPTIONS round-trip. Once a preflight is cached under Access-Control-Max-Age, the difference on subsequent requests shrinks to nothing, because the cached path also skips the OPTIONS. The saving is largest for one-off requests and cold caches.
Does sending JSON always trigger a preflight?
Yes. A Content-Type of application/json is not on the CORS-safelisted list, so any request carrying it is non-simple and triggers a preflight. Only application/x-www-form-urlencoded, multipart/form-data, and text/plain Content-Type values keep a POST simple.
Should I drop the Authorization header to avoid a preflight?
No. Authorization is a custom header that forces a preflight, but removing authentication to save a round-trip trades a real security boundary for a few milliseconds. Keep the credential and instead cache the preflight with a sensible Access-Control-Max-Age.
Related
- Preflight Performance Analysis — parent: quantifying and amortizing the preflight round-trip
- Measuring CORS Preflight Latency in Production — measure the saving you gain by staying simple
- Cache Duration Tuning & Max-Age — amortize the preflight you cannot avoid
- OPTIONS Endpoint Design — keep the preflight responder cheap when one is unavoidable