CORS Preflight Optimization — Reduce Latency on Every API Call
The hidden cost of CORS#
Every time your frontend calls an API on a different origin, the browser may send an extra HTTP request before the real one. This is the CORS preflight — an OPTIONS request that asks the server for permission.
On a fast API that responds in 50ms, the preflight adds another 50ms round-trip. That is a 100% latency increase on every single request. For APIs called frequently (autocomplete, polling, real-time updates), this adds up fast.
What triggers a preflight#
The browser divides cross-origin requests into two categories:
Simple requests (no preflight)#
A request is "simple" and skips the preflight if ALL of these are true:
- Method is
GET,HEAD, orPOST - Headers are only:
Accept,Accept-Language,Content-Language,Content-Type,Range - Content-Type is one of:
application/x-www-form-urlencoded,multipart/form-data,text/plain
Preflighted requests#
Everything else triggers a preflight:
- Using
PUT,PATCH,DELETEmethods - Sending
Authorizationheader - Sending
Content-Type: application/json - Any custom header (
X-Request-ID,X-API-Key, etc.)
In practice, almost every modern API call triggers a preflight because most APIs use JSON and authorization headers.
The preflight flow#
Here is exactly what happens:
1. Browser sends OPTIONS /api/users
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
2. Server responds:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
3. Browser sends actual POST /api/users
(only if preflight succeeded)
The browser caches the preflight response based on Access-Control-Max-Age. Until that cache expires, subsequent requests to the same endpoint skip the preflight.
Optimization 1: maximize preflight cache duration#
The most impactful optimization. Set Access-Control-Max-Age to the maximum value your security policy allows.
Common values:
| Value | Duration | Use case |
|---|---|---|
0 | No caching | Maximum security, worst performance |
600 | 10 minutes | Conservative default |
86400 | 24 hours | Good balance for most APIs |
7200 | 2 hours | Chrome's maximum (ignores higher values) |
Browser limits:
- Chrome: caps at 7200 seconds (2 hours), regardless of what the server sends
- Firefox: caps at 86400 seconds (24 hours)
- Safari: caps at 604800 seconds (7 days)
For maximum coverage, set Access-Control-Max-Age: 86400. Chrome will cap it at 2 hours, but Firefox and Safari will use the full 24 hours.
Server configuration example (Express):
app.use(cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
maxAge: 86400
}));
Nginx:
location /api/ {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Length' 0;
return 204;
}
}
Optimization 2: design APIs around simple requests#
If you can make requests qualify as "simple," the browser skips the preflight entirely.
Strategy: use query parameters instead of custom headers
// Triggers preflight (custom header)
GET /api/users
X-API-Key: abc123
// No preflight (query parameter)
GET /api/users?api_key=abc123
Strategy: use POST with form encoding for writes
// Triggers preflight (JSON content type)
POST /api/users
Content-Type: application/json
{"name": "Alice"}
// No preflight (form encoding)
POST /api/users
Content-Type: application/x-www-form-urlencoded
name=Alice
These trade-offs are rarely worth it for most APIs, but for high-frequency endpoints (telemetry, analytics beacons), eliminating the preflight entirely can be significant.
Optimization 3: wildcard vs specific origins#
Wildcard origin (*):
Access-Control-Allow-Origin: *
Allows any origin. Simple to configure but has critical limitations:
- Cannot be used with
credentials: 'include'(cookies, auth headers) - Cannot be used with
Access-Control-Allow-Headers: *in some browsers - Not suitable for authenticated APIs
Specific origin:
Access-Control-Allow-Origin: https://app.example.com
Required for any API that uses cookies or authorization. The server must dynamically set this header based on the incoming Origin header.
Dynamic origin pattern:
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com',
'https://admin.example.com'
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
next();
});
Important: Always include Vary: Origin when the Access-Control-Allow-Origin header changes based on the request. Without it, CDNs and proxies may cache the wrong origin value and serve it to other clients.
Optimization 4: credentials mode#
When using credentials: 'include' (for cookies or HTTP auth):
- The server MUST respond with a specific origin, not
* - The server MUST include
Access-Control-Allow-Credentials: true - The browser will NOT cache preflight responses in some cases
// Client
fetch('https://api.example.com/data', {
credentials: 'include'
});
// Server response
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Performance tip: If you do not need cookies, avoid credentials: 'include'. Use Authorization: Bearer tokens instead — they still trigger a preflight, but the preflight caching is more reliable.
Optimization 5: reduce the number of unique endpoints#
Preflight caching is per-URL. A preflight for GET /api/users/1 does not cover GET /api/users/2. Each unique URL requires its own preflight.
Strategies to reduce unique URLs:
- Batch endpoints:
POST /api/batchwith multiple operations in the body, instead of individual requests to different URLs - GraphQL: a single endpoint (
POST /graphql) means a single preflight to cache - Query parameters:
GET /api/users?id=1andGET /api/users?id=2share the same preflight because the cache key is the URL path, not query parameters (in most browsers)
Optimization 6: handle OPTIONS at the edge#
Serve preflight responses from your CDN or load balancer instead of passing them to your application server.
Cloudflare Worker:
addEventListener('fetch', event => {
if (event.request.method === 'OPTIONS') {
event.respondWith(handlePreflight(event.request));
}
});
function handlePreflight(request) {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': request.headers.get('Origin'),
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
'Vary': 'Origin'
}
});
}
This eliminates the round-trip to your origin server for preflight requests, reducing preflight latency from ~50ms to ~5ms.
Measuring preflight impact#
Use browser DevTools to quantify the cost:
- Open the Network tab
- Filter by method:
OPTIONS - Count the number of preflight requests per page load
- Sum the total time spent on preflights
Key metrics to track:
- Number of preflight requests per page load
- Average preflight latency
- Cache hit rate (preflights that were skipped due to caching)
- Percentage of API time spent on preflights
Common mistakes#
- Not setting
Access-Control-Max-Ageat all — defaults to 0 (no caching) in most servers - Setting max-age higher than the browser cap — Chrome ignores anything above 7200
- Forgetting
Vary: Origin— causes CDN to serve cached CORS headers for the wrong origin - Using
*with credentials — the browser silently rejects the response - Handling OPTIONS in application middleware — adds unnecessary processing; handle at the reverse proxy layer
Summary#
- Preflight requests double your API latency for cross-origin calls
Access-Control-Max-Age: 86400is the single most impactful optimization (Chrome caps at 2 hours)- Simple requests skip preflights entirely — useful for high-frequency endpoints
- Wildcard origins cannot be used with credentials
Vary: Originis mandatory when dynamically setting the allowed origin- Handle OPTIONS at the edge (CDN or load balancer) for minimum preflight latency
- Batch endpoints and GraphQL reduce the number of unique URLs that need preflights
Article #456 in the Codelit engineering series. Explore our full library of system design, infrastructure, and architecture guides at codelit.io.
Try these templates
OpenAI API Request Pipeline
7-stage pipeline from API call to token generation, handling millions of requests per minute.
8 componentsDiscord Voice & Communication Platform
Handles millions of concurrent voice calls with WebRTC, media servers, and guild-based routing.
10 componentsDistributed Rate Limiter
API rate limiting with sliding window, token bucket, and per-user quotas.
7 componentsContinue learning
Go deeper on performance and scale
As an Amazon Associate I earn from qualifying purchases. Codelit may receive a commission at no extra cost to you.
Systems Performance
Brendan Gregg · 2020
Reference tables and USE-method checklists for when every dashboard is green and latency is not.
4.8 (335)#1 in Computer Performance OptimizationHTTP/2 in Action
Barry Pollard · 2019
Multiplexing, server push and header compression, with the measurements showing when each helps.
Solving Identity Management in Modern Applications
Yvonne Wilson, Abhishek Hingnikar · 2022
Every OAuth 2 and OIDC flow drawn end to end, including which to pick and the tokens each returns.
4.0 (21)Kindle editionReal-World Cryptography
David Wong · 2021
Which primitive to pick for TLS, JWTs or end-to-end messaging, and what breaks when you pick wrong.
4.7 (100)#14 in Client-Server Networking Systems