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.

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 });

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

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.