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 it on Codelit
AI Architecture Review
Get an AI audit covering security gaps, bottlenecks, and scaling risks
Related articles
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 componentsBuild this architecture
Generate an interactive architecture for CORS Preflight Optimization in seconds.
Try it in Codelit →
Comments