Measuring CORS Preflight Latency in Production

The cost of a CORS preflight is real but easy to overlook: on a cache miss, every non-simple cross-origin request pays an extra OPTIONS round trip before the actual request is even sent. In development that latency hides behind a fast local network; in production, across real networks and CDNs, it becomes a measurable slice of API latency. Measuring it is what turns preflight optimization from guesswork into a number you can track.

This page is part of Preflight Performance Analysis, which explains the mechanics of preflight cost and how Access-Control-Max-Age amortizes it.

The Symptom This Resolves

You suspect preflight is inflating your cross-origin API latency, but you have no number. Perhaps p95 latency for a POST /orders from the browser is far higher than the same call measured server-side, and the gap looks like one extra round trip:

Server-side p95 for POST /orders:      42 ms
Browser-observed p95 for POST /orders: 118 ms   ← where does the extra ~70 ms come from?

Root Cause

Per the WHATWG Fetch Standard, a non-simple request triggers a preflight whose result is cached per (origin, URL, method, header set) for the duration of Access-Control-Max-Age. On a cache miss, the browser serializes the preflight before the actual request, so the user-perceived latency is preflight RTT + actual RTT. The browser-versus-server gap above is that preflight RTT. If your cache hit ratio is low — because Access-Control-Max-Age is short or a per-request header keeps changing the cache key — most requests pay it.

Laid out on one time axis, the miss and the hit are the same call with one extra round trip in front of it:

Preflight cache miss versus hit on one time axis Two horizontal timelines sharing a millisecond scale. The cache-miss row spends about seventy milliseconds on an OPTIONS preflight before the forty-eight millisecond POST, finishing at one hundred and eighteen milliseconds. The cache-hit row starts the POST at zero and finishes at forty-eight, and the seventy millisecond difference is marked between the two end points. 0 25 50 75 100 125 ms Preflight cache MISS — first call OPTIONS preflight, about 70 ms POST /orders, 48 ms 118 ms Preflight cache HIT — inside Max-Age POST /orders, 48 ms 48 ms about 70 ms of user-perceived latency, paid only on a miss

Prerequisite State

Step-by-Step

Step 1 — Instrument the client with the Resource Timing API

A PerformanceObserver captures timing for every resource, including cross-origin API calls. The preflight itself is not a separate entry, but a large gap before the response phase on the actual request signals a cache miss:

const API_HOST = 'api.example.com';

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntriesByType('resource')) {
    if (!entry.name.includes(API_HOST)) continue;

    // Time spent before bytes start flowing back for the actual request.
    // On a preflight cache miss this includes the OPTIONS round trip.
    const preConnect = entry.requestStart - entry.fetchStart;
    const waiting = entry.responseStart - entry.requestStart;

    navigator.sendBeacon('/rum/cors-timing', JSON.stringify({
      url: entry.name,
      fetchToRequest: Math.round(preConnect),
      ttfb: Math.round(waiting),
      duration: Math.round(entry.duration),
    }));
  }
});

observer.observe({ type: 'resource', buffered: true });

The milestones that observer reads line up like this, with the preflight hiding inside the very first span:

Where the preflight hides in a PerformanceResourceTiming entry A horizontal milestone axis running from fetchStart through connectEnd, requestStart, responseStart and responseEnd. Three spans sit below it: fetchStart to requestStart, the waiting or time-to-first-byte span, and the download span. A callout marks the first span as the place a preflight round trip is absorbed, since it has no timing entry of its own. fetchStart requestStart responseEnd connectEnd responseStart fetchStart to requestStart waiting (TTFB) response download On a preflight cache miss the whole OPTIONS round trip is absorbed into this one span. Server processing plus network for the actual request only — subtract it to isolate the preflight cost. entry.duration spans fetchStart to responseEnd — the preflight is inside it, never beside it

Step 2 — Log OPTIONS requests separately on the server

To isolate the preflight precisely, record OPTIONS requests with their timing. In Nginx, add the method and request time to the log format:

log_format cors '$time_iso8601 $request_method $uri '
                'status=$status rt=$request_time origin=$http_origin';

server {
  location /api/ {
    access_log /var/log/nginx/cors.log cors;
    # ... CORS handling ...
  }
}

Step 3 — Compute the preflight cache hit ratio

Compare the count of OPTIONS requests to the count of actual requests to the same paths. A low OPTIONS-to-actual ratio means the browser cache is doing its job:

# Over the last log window, per method
awk '{print $2}' /var/log/nginx/cors.log | sort | uniq -c
# e.g.  1200 OPTIONS   /   18400 POST   → ratio ≈ 1:15, healthy
#       9100 OPTIONS   /    9800 POST   → ratio ≈ 1:1, cache barely hit

Drawn to scale, the two log windows are impossible to confuse:

OPTIONS-to-actual request ratio in two log windows Two pairs of horizontal bars drawn on the same scale. In the healthy window twelve hundred OPTIONS requests sit against eighteen thousand four hundred POSTs. In the unhealthy window ninety-one hundred OPTIONS sit against ninety-eight hundred POSTs, so almost every call pays its own preflight round trip. Healthy — Max-Age is amortizing the preflight counts over one 24-hour log window OPTIONS 1,200 POST 18,400 About one preflight per fifteen real requests — the browser cache is doing its job. Unhealthy — a per-request header keeps changing the cache key OPTIONS 9,100 POST 9,800 About one preflight per real request — nearly every call pays the extra round trip.

A ratio near 1:1 points at a short Access-Control-Max-Age or an unstable header set — tune it per How to Set Access-Control-Max-Age Effectively.

Step 4 — Alert on regressions

Feed the beacon data and the log ratio into your metrics pipeline and alert when either the OPTIONS p95 latency or the OPTIONS-to-actual ratio rises — a common regression when a client library starts adding a new custom header on every request, breaking the cache key described in Reducing Preflight Frequency with Header Caching.

Verification

# Confirm OPTIONS requests are being logged with timing
grep OPTIONS /var/log/nginx/cors.log | tail -5

Security Boundary Note

When you log the Origin header for analysis, treat it as untrusted input — it is attacker-controllable and must never be used to make an allow decision, only to observe one. Do not build a dynamic allowlist that trusts whatever origins appear in the logs; keep validation explicit as described in Dynamic Origin Validation Patterns. Also avoid logging credentials — never record Authorization or Cookie values alongside the timing data.

Common Mistakes

Issue Technical impact Mitigation
Expecting a standalone OPTIONS entry in Resource Timing The preflight has no own entry; you conclude there is no preflight Correlate the client entry gap with server OPTIONS logs
Measuring only in development Local RTT is ~0 ms, hiding the real preflight cost Measure with production networks and CDNs in the path
Ignoring the cache hit ratio Latency looks fine on average but spikes on every cache miss Track the OPTIONS-to-actual ratio, not just mean latency

FAQ

Can the Resource Timing API see the preflight OPTIONS request?

Not as its own entry. The preflight cost is folded into the actual request’s timing: on a cache miss, the actual request’s entry shows a larger gap before the response phase. To isolate preflight cost precisely, correlate the client-side entry with server access logs that record the OPTIONS request separately.

How do I compute the preflight cache hit ratio?

Count OPTIONS requests against actual (GET/POST/PUT/…) requests to the same endpoints over a window. A healthy Access-Control-Max-Age yields far fewer OPTIONS than actual requests because the browser caches each preflight. A ratio near 1:1 means the cache is not being hit — look for a per-request header that keeps changing the cache key.

Does HTTP/2 remove the need to measure preflight latency?

No. HTTP/2 and HTTP/3 multiplex over one connection, removing connection setup for the preflight but not the extra round trip itself. The preflight still costs one round trip on a cache miss. Measuring it quantifies the benefit of raising the cache hit ratio.