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

Simple vs preflight decision flow A request passes three tests: safelisted method, only safelisted headers, and safelisted content type. Passing all three sends the request directly with no preflight in green. Failing any one routes to a preflight OPTIONS round-trip in red. Cross-origin request Safelisted method? GET / HEAD / POST Only safelisted headers? Safelisted Content-Type? Any NO -> preflight OPTIONS + extra RTT All YES -> simple sent directly
Figure — a request must pass all three tests to skip the preflight.

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.