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:

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:

  1. Ordering. The actual request is queued behind the preflight. No pipelining or speculative dispatch is permitted.
  2. 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 to Access-Control-Max-Age seconds. A cache hit skips the OPTIONS entirely. This is covered in depth in Cache Duration Tuning & Max-Age.
  3. 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.

Preflight waterfall: cold path vs cached path Two stacked timelines. The first request pays TCP/TLS setup, then an OPTIONS preflight round-trip shown in red, then the actual request. The warm cached path reuses the connection and skips the preflight, finishing after only the actual request round-trip, with the saved time bracketed. First request (cold, no cache) TCP / TLS OPTIONS preflight RTT actual request RTT Warm path (cached + reused) actual request RTT saved on cache hit: setup + 1 RTT elapsed time
Figure — the cached, connection-reused path removes both the setup segment and the serialized preflight 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:

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:

  1. DNS resolution for the API host.
  2. TCP handshake (1 RTT).
  3. TLS handshake (1–2 RTT, or 0-RTT with session resumption).
  4. OPTIONS preflight (1 RTT).
  5. 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

  1. Open DevTools → Network panel and enable Disable cache so you observe the cold path.
  2. Trigger the cross-origin request and locate the OPTIONS entry immediately preceding the actual request.
  3. Hover the OPTIONS entry and read its Timing breakdown — the Waiting (TTFB) segment is essentially the preflight round-trip.
  4. Compare the timestamp at which the OPTIONS completes 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

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.


Topics in This Section