CORS performance usually gets treated like background noise until your frontend starts making hundreds of API calls and every second request triggers an OPTIONS preflight. Then it becomes very obvious: bad CORS config can waste latency, server CPU, and CDN cache efficiency.
I’ve seen teams obsess over query performance while every browser quietly burns extra round trips on preflights they could have avoided.
This guide is the practical version: what gets cached, what does not, and what headers to set when you want cross-origin requests to be fast without turning your policy into mush.
The two kinds of CORS caching that matter
There are really two separate cache stories:
-
Preflight cache
- Browser caches the result of an
OPTIONSpreflight. - Controlled mainly by
Access-Control-Max-Age.
- Browser caches the result of an
-
Actual response cache
- The real
GET/POSTresponse may be cached by the browser, a CDN, or a proxy. - Controlled by normal HTTP caching headers like
Cache-Control,ETag, andVary.
- The real
People mix these up all the time. Access-Control-Max-Age does not cache your API response body. It only caches permission to make the cross-origin request.
What causes a preflight
A browser sends a preflight when a request is “non-simple”. Common triggers:
- Method is not
GET,HEAD, orPOST Content-Typeis not one of:application/x-www-form-urlencodedmultipart/form-datatext/plain
- Custom headers like:
AuthorizationX-Requested-WithX-API-Key
Example that usually triggers preflight:
fetch("https://api.example.com/profile", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer token"
},
body: JSON.stringify({ name: "Ada" })
});
The browser will first send something like:
OPTIONS /profile HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: content-type, authorization
If your server responds correctly, the browser may cache that permission.
Use Access-Control-Max-Age aggressively, but not blindly
This is the main performance lever for preflight traffic.
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
That tells the browser it can reuse the preflight result for up to 24 hours.
A good baseline:
- 600 seconds: conservative
- 3600 seconds: solid default
- 86400 seconds: great when your CORS policy rarely changes
The catch: browser caps vary. Some browsers won’t honor the full value. Still worth setting a high number because the browser will clamp it if needed.
Express example
import express from "express";
const app = express();
app.use((req, res, next) => {
const origin = req.headers.origin;
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com"
]);
if (allowedOrigins.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
}
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "86400");
if (req.method === "OPTIONS") {
return res.status(204).end();
}
next();
});
app.get("/api/data", (req, res) => {
res.json({ ok: true });
});
app.listen(3000);
If you dynamically reflect the request origin, add Vary: Origin. Without it, shared caches can serve the wrong CORS headers to the wrong site.
Avoid preflights when the request does not need them
Sometimes the fastest preflight is the one you never trigger.
If you control both frontend and backend, ask whether you really need:
- custom headers
- JSON for tiny writes
- non-simple methods
A classic tradeoff:
Triggers preflight
fetch("https://api.example.com/search", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ q: "cors" })
});
Can avoid preflight
const body = new URLSearchParams({ q: "cors" });
fetch("https://api.example.com/search", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body
});
I wouldn’t contort an API just to dodge preflight everywhere. That gets ugly fast. But for hot paths like search suggestions, telemetry, or lightweight form submits, it can be worth it.
Cache the actual API response separately
A preflight cache hit still doesn’t mean your API response is cached.
For read-heavy endpoints, use normal HTTP caching:
Cache-Control: public, max-age=60, s-maxage=300
ETag: "user-list-v42"
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin, Accept-Encoding
This gives you:
- browser cache for 60 seconds
- shared cache/CDN freshness for 300 seconds
- validation via
ETag
Example with conditional requests
GET /users HTTP/1.1
Origin: https://app.example.com
If-None-Match: "user-list-v42"
Server:
HTTP/1.1 304 Not Modified
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin, Accept-Encoding
ETag: "user-list-v42"
Cache-Control: public, max-age=60, s-maxage=300
That still needs correct CORS headers on the 304. Missing them breaks browsers in annoying ways.
Vary is where CDN cache efficiency lives or dies
If your CORS policy changes per origin, your cache key probably needs to vary by origin.
Dynamic origin reflection
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
That is correct when multiple allowed origins exist.
Public API with wildcard
Access-Control-Allow-Origin: *
No Vary: Origin needed there, because the response is the same for everyone.
GitHub’s API is a good real-world example of a public CORS posture:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning
That wildcard is cache-friendly because every origin gets the same answer. If your API is truly public and does not use credentials, * is the simplest and fastest option.
Expose only the response headers your frontend actually reads
Browsers hide most response headers from JavaScript unless you expose them.
Example:
Access-Control-Expose-Headers: ETag, Link, X-RateLimit-Remaining, X-RateLimit-Reset
That matters for performance too. Why? Because exposed headers often drive client-side caching and pagination behavior.
For example, if your frontend reads ETag, it can make conditional requests. If it reads Link, it can paginate without parsing response bodies for navigation metadata.
Nginx example
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin https://app.example.com always;
add_header Access-Control-Allow-Methods "GET, POST, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age 86400 always;
add_header Vary Origin always;
return 204;
}
add_header Access-Control-Allow-Origin https://app.example.com always;
add_header Access-Control-Expose-Headers "ETag, Link, X-RateLimit-Remaining" always;
add_header Vary Origin always;
proxy_pass http://backend;
}
Credentials make caching harder
Once you send cookies or HTTP auth cross-origin, things get stricter.
You cannot use:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
That combination is invalid.
Instead:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Credentialed responses are also much less cache-friendly in shared layers. If a response is user-specific, don’t expect your CDN to save you unless you’ve designed around that carefully.
My rule: if an endpoint can be public and token-based without cookies, life gets easier. Browser cookie auth plus cross-origin caching is where clean architectures go to suffer.
Don’t forget preflight responses should be cheap
Your OPTIONS handler should not hit the database, session store, or auth backend.
Bad:
- loading user session
- doing tenant lookup from DB
- generating full app middleware stack
Good:
- check
Origin - check requested method/headers
- return
204
Example in Go:
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "https://app.example.com" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "86400")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
A practical checklist
If CORS is slowing down your API, this is where I’d start:
- Set
Access-Control-Max-Ageto3600or higher - Return
204 No Contentfor preflight - Keep
OPTIONShandlers stateless and cheap - Avoid custom headers on hot paths when possible
- Use
Access-Control-Allow-Origin: *for truly public, non-credentialed APIs - Add
Vary: Originwhen origin-specific responses are used - Cache real responses with
Cache-ControlandETag - Include CORS headers on
304 Not Modifiedresponses too - Expose headers your frontend needs for pagination, rate limits, and validation
Copy-paste starter configs
Public API
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: ETag, Link, Retry-After, X-RateLimit-Remaining, X-RateLimit-Reset
Access-Control-Max-Age: 86400
Cache-Control: public, max-age=60, s-maxage=300
ETag: "v1-resource"
Private SPA API
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Expose-Headers: ETag, Link
Access-Control-Max-Age: 3600
Vary: Origin
Cache-Control: private, max-age=0, must-revalidate
Fast preflight response
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Vary: Origin
CORS performance is mostly about reducing needless variation and needless round trips. Keep the policy predictable, keep preflights cacheable, and let normal HTTP caching do the heavy lifting for the actual data. That’s where the wins are.