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
- Cross-origin API calls are already working (this page measures cost, it does not fix broken CORS).
- You can deploy a small client-side script or already run a RUM/telemetry pipeline.
- You have access to server or CDN access logs that record the request method.
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.