Preflight Performance Analysis
A CORS preflight inserts an extra network round-trip in front of the request you actually care about: before the browser sends your PUT or your application/json POST, it first sends an OPTIONS request and waits for the response. Per the WHATWG Fetch Standard (§4.8, “CORS-preflight fetch”), this is a hard ordering dependency — the actual request is not dispatched until the preflight succeeds. This page is part of Preflight Request Optimization & Caching Strategies, and it focuses on turning that abstract “extra round-trip” into numbers you can measure: how large the penalty really is, how the preflight cache amortizes it, and how HTTP/2 and HTTP/3 multiplexing change the shape of the request waterfall.
The problem: an invisible serialized round-trip
The preflight cost is easy to underestimate because it does not appear in your application code. Your fetch() call looks like one request, but on the wire it is two: an OPTIONS probe followed by the real method. The browser blocks the real request until the OPTIONS response returns, so the two round-trips are strictly serialized — they cannot overlap.
The size of the penalty depends on where the cost lands:
- On a warm connection (already open, TLS established), a preflight costs approximately one RTT — the time for the
OPTIONSto travel to the server and the204to return. - On a cold connection, the preflight is often what triggers DNS resolution, the TCP handshake, and the TLS handshake. In that case the “preflight cost” bundles connection setup, and the first cross-origin request can be several hundred milliseconds slower than a same-origin equivalent.
- Across the preflight cache window, the cost is amortized: one
OPTIONSround-trip is paid once and then reused for every matching request untilAccess-Control-Max-Ageexpires.
The goal of this page is to quantify each of those, so you can decide whether a given preflight is worth eliminating (see Preflight vs Simple Request: The Performance Cost) or simply caching for longer.
Spec anchor: what the browser serializes
The Fetch Standard defines the preflight as a distinct fetch that must complete successfully before the “actual request” runs. The relevant behavior for performance analysis is threefold:
- Ordering. The actual request is queued behind the preflight. No pipelining or speculative dispatch is permitted.
- Caching. A successful preflight response is stored in the per-origin preflight cache keyed by the tuple
(origin, URL, method, sorted request-headers)for up toAccess-Control-Max-Ageseconds. A cache hit skips theOPTIONSentirely. This is covered in depth in Cache Duration Tuning & Max-Age. - Connection reuse. The preflight and the actual request target the same origin, so under HTTP/1.1 keep-alive, HTTP/2, or HTTP/3 they share one connection. The transport handshake is paid once, not twice.
Understanding these three rules is enough to reason about every number in the waterfall below.
What drives the cost: reference table
| Dimension | Affects preflight cost? | How |
|---|---|---|
Access-Control-Max-Age |
Yes — dominant | Sets how many actual requests amortize a single OPTIONS round-trip. A 0 value forces a preflight on every call; a 600 value spreads the cost across up to ten minutes of traffic. |
| Round-trip time (RTT) / geographic distance | Yes — dominant | The preflight cost is fundamentally one RTT. A 20 ms regional link and a 250 ms intercontinental link pay proportionally different penalties for the identical preflight. |
| HTTP version (1.1 vs 2 vs 3) | Partial | Does not remove the serialized OPTIONS round-trip, but multiplexing avoids opening a second connection and lets the actual request reuse the warm one. HTTP/3 also removes head-of-line blocking at the transport layer. |
| Connection reuse / keep-alive | Yes | On a cold connection the preflight triggers DNS + TCP + TLS; on a warm connection only the application round-trip remains. |
| TLS session resumption (0-RTT / session tickets) | Partial | Reduces the handshake portion of a cold-connection preflight, shrinking the setup component without touching the application round-trip. |
| Request classification (simple vs non-simple) | Yes — binary | A request that stays “simple” never triggers a preflight at all, eliminating the round-trip entirely rather than caching it. |
| Number of distinct cache-key tuples | Yes | Each unique (origin, URL, method, headers) combination is a separate preflight. Many distinct tuples means many cold OPTIONS calls that never share a cache entry. |
| CDN / proxy edge termination | Yes | Terminating OPTIONS at the edge shortens the preflight RTT to the nearest POP instead of the origin — see Proxy Bypass Strategies. |
The request waterfall: cold path vs cached path
The clearest way to see the penalty is to draw the two waterfalls side by side. The cold path pays for connection setup, the preflight round-trip, and then the actual request. The warm, cached path reuses the connection and skips the preflight, leaving only the actual request round-trip.
The visual makes the amortization obvious: the red OPTIONS segment appears once per cache window, not once per request. If a single cached preflight covers 500 actual requests over its ten-minute window, the amortized preflight cost per request is the one round-trip divided by 500 — effectively negligible. The danger cases are the ones where the cache never gets a chance to help: a max-age of 0, a constantly-changing cache key, or Origin: null from a sandboxed frame (which browsers never cache).
HTTP/2 and HTTP/3 multiplexing effects
A common misconception is that upgrading to HTTP/2 or HTTP/3 “fixes” preflight overhead. It does not remove the serialized round-trip, but it changes two things that matter:
- No second connection. Under HTTP/1.1 without keep-alive, the
OPTIONSand the actual request could each pay their own TCP + TLS setup. Under HTTP/2 and HTTP/3 there is a single multiplexed connection, so the handshake is paid once and both requests reuse it. The preflight and the actual request become two streams on the same pipe. - Head-of-line blocking. HTTP/2 multiplexes streams but still rides a single TCP connection, so a lost packet stalls all streams at the transport layer. HTTP/3 runs over QUIC, which isolates streams, so an unrelated lost packet does not delay the preflight or the actual request. This does not shrink the preflight’s own round-trip, but it makes the measured timing more stable under packet loss.
The invariant to remember: multiplexing hides connection setup, not the preflight’s ordering dependency. No protocol version lets the browser send the actual request before the OPTIONS response returns.
Connection reuse and the cold-start penalty
The single largest swing in measured preflight cost comes from whether the connection is warm. Consider a cross-origin POST with a JSON body to an API on a fresh page load:
- DNS resolution for the API host.
- TCP handshake (1 RTT).
- TLS handshake (1–2 RTT, or 0-RTT with session resumption).
OPTIONSpreflight (1 RTT).- Actual
POST(1 RTT).
Steps 1–3 are connection setup and steps 4–5 are application round-trips. On a cold connection the preflight is entangled with setup and can appear to cost far more than one RTT. Once the connection is warm and the preflight is cached, only step 5 remains. This is why the first cross-origin request on a page feels disproportionately slow while later ones are fast — a pattern you will confirm when you follow the methodology in Measuring CORS Preflight Latency in Production.
Step-by-step measurement methodology
Measure the cost in three complementary places: the browser DevTools waterfall (one session), the Timing APIs (many sessions), and the server access logs (aggregate cache behavior).
Step 1 — Read the waterfall in DevTools
- Open DevTools → Network panel and enable Disable cache so you observe the cold path.
- Trigger the cross-origin request and locate the
OPTIONSentry immediately preceding the actual request. - Hover the
OPTIONSentry and read its Timing breakdown — the Waiting (TTFB) segment is essentially the preflight round-trip. - Compare the timestamp at which the
OPTIONScompletes to the timestamp at which the actual request starts; the gap is the serialized dependency you are paying for.
Step 2 — Quantify with the Resource Timing API
The Resource Timing API exposes a PerformanceResourceTiming entry per request. Read the entries to separate preflight duration from the actual request duration across real sessions:
// Log the timing of OPTIONS preflight entries as they occur.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// The initiatorType for a fetch/XHR preflight is reported by the browser;
// filter to your API host and inspect the transfer characteristics.
if (!entry.name.startsWith('https://api.example.com/')) continue;
const rtt = Math.round(entry.responseEnd - entry.requestStart);
const reusedConnection = entry.connectStart === entry.connectEnd; // no new handshake
console.log({
url: entry.name,
durationMs: Math.round(entry.duration),
applicationRttMs: rtt,
reusedConnection,
transferSize: entry.transferSize, // 0 indicates a cache hit
});
}
});
observer.observe({ type: 'resource', buffered: true });
A transferSize of 0 on a preflight-eligible request indicates the browser served it from cache and skipped the network entirely — that is your amortization working.
Step 3 — Correlate with server access logs
The browser only sees its own session. To understand fleet-wide cache behavior, count OPTIONS versus non-OPTIONS requests at the origin over the same window:
# Ratio of preflight OPTIONS to actual requests per path (nginx access log).
awk '$6 ~ /OPTIONS/ {opt[$7]++} $6 !~ /OPTIONS/ {act[$7]++}
END { for (p in act) printf "%-30s OPTIONS=%d actual=%d ratio=%.3f\n",
p, opt[p], act[p], (act[p] ? opt[p]/act[p] : 0) }' /var/log/nginx/access.log
A ratio near 0 means the preflight cache is amortizing well. A ratio near 1 means almost every actual request is preceded by a fresh OPTIONS — the cache is being missed or defeated.
Step 4 — Compute the amortized per-request cost
Combine the two: multiply the measured preflight round-trip (Step 2) by the observed OPTIONS-per-actual ratio (Step 3). If a preflight costs 120 ms and your ratio is 0.02, the amortized preflight penalty is roughly 2.4 ms per actual request. If the ratio is 1.0, you are paying the full 120 ms on every call and should either extend Access-Control-Max-Age or eliminate the preflight by keeping the request simple.
Verification checklist
curl -si -X OPTIONS https://api.example.com/data \ -H "Origin: https://app.example.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Content-Type" | grep -i access-control-max-age
Common mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
| Assuming HTTP/2 removes the preflight cost | The serialized OPTIONS round-trip remains; only connection setup is shared. Teams “upgrade to fix it” and see no change to the ordering dependency |
Cache the preflight and, where possible, keep the request simple; treat protocol version as orthogonal to the round-trip |
| Measuring only the warm path | DevTools with caching on hides the OPTIONS, so the preflight looks free while first-visit users pay full cost |
Always measure with Disable cache first, then measure the warm path separately |
| Reporting preflight cost as a fixed millisecond number | The cost is one RTT, which varies from ~20 ms regionally to ~300 ms intercontinentally; a single figure misleads | Report the cost in round-trips and multiply by measured per-region RTT |
| Ignoring the cache-key cardinality | Many distinct (origin, URL, method, headers) tuples each pay their own cold OPTIONS, so the aggregate cost stays high despite a long max-age |
Consolidate request-header sets and routes so more requests share one cache entry |
Setting Access-Control-Max-Age: 0 for “freshness” |
Forces a preflight on every single call, converting an amortized cost into a per-request cost | Use a non-zero max-age sized to your policy-change cadence |
FAQ
How much latency does a CORS preflight actually add?
A preflight adds one full network round-trip before the actual request can begin. On a connection that is already open, that is roughly one RTT — commonly 20–80 ms on regional links and 150–300 ms on intercontinental links. If the preflight also forces DNS, TCP, and TLS setup on a cold connection, the added cost can exceed several hundred milliseconds.
Does HTTP/2 or HTTP/3 eliminate the preflight cost?
No. HTTP/2 and HTTP/3 multiplex many streams over one connection, so the preflight OPTIONS and the actual request share the same connection and avoid repeated TCP/TLS setup. But the preflight is still a strict ordering dependency: the browser will not send the actual request until the OPTIONS response arrives, so you still pay one serialized round-trip regardless of protocol version.
Why does the first cross-origin request feel slow but later ones do not?
The first request pays for the preflight round-trip plus any connection setup. Once the OPTIONS response is cached under Access-Control-Max-Age, subsequent matching requests skip the preflight entirely and reuse the warm connection, so only the actual request round-trip remains.
How do I measure the preflight cache hit ratio in production?
Compare the count of OPTIONS requests to the count of actual cross-origin requests in your server access logs over the same window. A ratio near zero OPTIONS per actual request means the cache is working; a ratio near one means nearly every request is paying for a preflight and the cache is being missed or defeated.
Related
Topics in This Section
Preflight vs Simple Request: The Performance Cost
Compare the latency of a preflight OPTIONS round-trip versus a simple CORS request, and learn how to restructure requests with safelisted content types to avoid preflight.
Measuring CORS Preflight Latency in Production
Measure real CORS preflight cost in production: read OPTIONS timing from the Resource Timing API, compute preflight cache hit ratio from server logs, and instrument RUM to catch preflight regressions.