Choosing Access-Control-Max-Age for Fast-Changing APIs
The failure this page prevents:
Access to fetch at 'https://api.orbitpay.io/v3/payouts/38c1' from origin
'https://dash.orbitpay.io' has been blocked by CORS policy: Method PATCH is
not allowed by Access-Control-Allow-Methods in preflight response.
The confusing part is that the server does allow PATCH on that route. It started allowing it four minutes ago, when the workspace was upgraded to a plan that includes edits. The browser is not asking — it is acting on a preflight it cached before the upgrade, and it will keep doing so until the entry expires.
Root Cause
A preflight cache entry is keyed on the request origin, the request URL, the credentials mode, and the specific method or header name the browser asked about. It is not keyed on anything that identifies who is asking: no Authorization header, no session cookie, no workspace identifier, no build number of the code that produced the answer. So the moment your allowed methods, allowed headers or origin allowlist can change faster than the cache expires — because of a plan upgrade, a feature flag, a per-tenant policy, or simply a frequent deploy — the browser is answering a question with data that predates the change. The specification treats this as intended behaviour: Access-Control-Max-Age is a promise that the answer stays valid for that long, and a fast-changing API is one that cannot honour a long promise.
This page narrows the general tuning advice in Cache Duration Tuning & Max-Age to the volatile case, where the correct number is derived from your deploy and entitlement cadence rather than from the browser cap alone. If you have not yet set the header at all, start with How to Set Access-Control-Max-Age Effectively and come back for the volatility question.
What is in the key — and, more importantly, what is not — decides which changes the cache can even notice:
Prerequisite State
- The
OPTIONSresponse already carries a correctAccess-Control-Allow-Origin,Access-Control-Allow-MethodsandAccess-Control-Allow-Headersset for the routes in question. - You know which layer emits the header — application, reverse proxy or CDN — and that only one of them does, so the value cannot be doubled or overwritten downstream.
- You can name your deploy cadence and your rollback window in minutes. Both are inputs to the number you are about to choose.
Vary: Originis present on every response that reflects an origin, so a shared cache cannot serve one tenant’s answer to another.
Step-by-Step
Step 1 — Classify each route by policy volatility
Not every route on a fast-changing API is fast-changing. Sort them into four classes and give each class one number:
| Route class | Example | What can change under it | Max-Age |
|---|---|---|---|
| Static contract | GET /v3/reference/currencies |
nothing without a versioned release | 600 |
| Deploy-coupled | POST /v3/payouts |
allowed headers change with each deploy | 120 |
| Entitlement-gated | PATCH /v3/payouts/{id} |
methods depend on the workspace plan | 30 |
| Revocable admin | DELETE /v3/admin/members/{id} |
role can be withdrawn mid-session | 0 |
The last row is the only place a zero belongs. Everywhere else, zero trades a real latency cost for a guarantee you can get more cheaply.
Step 2 — Take the ceiling as the smallest of three windows
The value you ship is not a preference; it is the minimum of three hard limits:
- The engine cap. Blink and WebKit clamp to 600 seconds, Gecko to 86 400. Anything above 600 only lengthens the window for a minority of users, and it lengthens exactly the window you are trying to shorten.
- The rollback window. If a bad policy takes you eight minutes to detect and revert, a 600-second cache means the last affected client keeps acting on it for ten minutes after the revert lands.
- The entitlement window. If a plan upgrade must take effect within a minute of payment, no route whose methods depend on the plan may cache longer than a minute.
The exposure is not average, it is worst case: every client caches at a different moment, so the last one to expire defines when the fleet is consistent again.
Step 3 — Emit the value per route, not per server
One global number forces the whole API down to the volatility of its worst route. Drive the value from the path in whichever layer answers OPTIONS.
Express, with the table from Step 1 encoded as prefixes:
const PREFLIGHT_TTL = [
[/^\/v3\/admin\//, 0],
[/^\/v3\/payouts\//, 30],
[/^\/v3\/reference\//, 600],
];
function ttlFor(path) {
const hit = PREFLIGHT_TTL.find(([re]) => re.test(path));
return String(hit ? hit[1] : 120); // deploy-coupled default
}
app.options(/^\/v3\//, (req, res) => {
const origin = req.headers.origin;
if (origin !== "https://dash.orbitpay.io") return res.status(403).end();
res.set({
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type, X-Workspace-Id",
"Access-Control-Max-Age": ttlFor(req.path),
"Vary": "Origin",
});
res.status(204).end();
});
Nginx, using a map so the value is chosen before the request reaches a handler:
map $uri $preflight_ttl {
default "120";
"~^/v3/admin/" "0";
"~^/v3/payouts/" "30";
"~^/v3/reference/" "600";
}
location /v3/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://dash.orbitpay.io" always;
add_header Access-Control-Allow-Methods "GET, POST, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Workspace-Id" always;
add_header Access-Control-Max-Age $preflight_ttl always;
add_header Vary "Origin" always;
return 204;
}
proxy_pass http://payouts_upstream;
}
The same rule in a Go service, where the middleware owns both the preflight and the actual response:
package main
import (
"net/http"
"strings"
)
func ttlFor(path string) string {
switch {
case strings.HasPrefix(path, "/v3/admin/"):
return "0"
case strings.HasPrefix(path, "/v3/payouts/"):
return "30"
case strings.HasPrefix(path, "/v3/reference/"):
return "600"
default:
return "120"
}
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Origin") != "https://dash.orbitpay.io" {
next.ServeHTTP(w, r)
return
}
h := w.Header()
h.Set("Access-Control-Allow-Origin", "https://dash.orbitpay.io")
h.Set("Access-Control-Allow-Credentials", "true")
h.Add("Vary", "Origin")
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
h.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Workspace-Id")
h.Set("Access-Control-Max-Age", ttlFor(r.URL.Path))
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
Step 4 — Shrink before you change, restore after
The number is only half the technique. A policy change shipped while a long value is in flight is a change that arrives at each client at a different, unknowable moment. Sequence the two so the cache is already short when the change lands:
Step 5 — Measure the reuse you are actually buying
A short value is only expensive if clients preflight often. Count the methods in the access log and compare the number of OPTIONS requests with the number of non-simple requests they authorised:
awk -F'"' '{split($2, r, " "); m[r[1]]++} END {for (k in m) printf "%-8s %d\n", k, m[k]}' \
/var/log/nginx/access.log
A ratio near one means every write is paying for its own preflight and the value is too low for that route class. A ratio above five means the cache is doing real work and you can afford to shorten it further if volatility demands. Route-level latency attribution is covered in Measuring CORS Preflight Latency in Production, and the complementary trick of shrinking the number of distinct preflights is in Reducing Preflight Frequency with Header Caching.
Verification
curl -sS -D - -o /dev/null -X OPTIONS https://api.orbitpay.io/v3/payouts/38c1 \
-H 'Origin: https://dash.orbitpay.io' \
-H 'Access-Control-Request-Method: PATCH' \
-H 'Access-Control-Request-Headers: authorization, x-workspace-id' \
| grep -iE '^(HTTP|access-control-max-age|vary)'
Security Boundary Note
Access-Control-Max-Age is a performance dial, never an access-control dial. A cached grant does not let the browser read anything — the actual request still travels to your server, which must re-authorise it on every call. The real risk of a long value on a volatile API is the reverse: an origin you removed from the allowlist keeps skipping the preflight and keeps sending credentialed requests, so your logs fill with rejections and your incident timeline stretches by the length of the cache. Revoke on the actual response, keep the allowlist exact as described in Dynamic Origin Validation Patterns, and never widen Access-Control-Allow-Methods merely to avoid re-preflighting after a policy change.
Common Mistakes
| Mistake | Technical impact | Fix |
|---|---|---|
One global Access-Control-Max-Age |
The whole API inherits the volatility of its least stable route | Emit the value per path class from the layer answering OPTIONS |
| Shipping the policy change and the shorter value together | Clients still hold the old long entry, so the shorter value only applies after the change has already gone stale | Ship the shrink first and let it drain for the length of the old value |
| Assuming a plan upgrade invalidates the cached grant | Entitlement is not part of the cache key, so the browser keeps using the pre-upgrade answer | Cap entitlement-gated routes at the entitlement window |
Setting 0 everywhere to be safe |
A preflight before every write, doubling request count and adding a round trip per call | Reserve 0 for revocable admin routes; use 10–60 s elsewhere |
FAQ
Does the preflight cache key include the user’s session or bearer token?
No. The browser keys a preflight entry on the origin, the request URL, the credentials mode, and the method or header name that was asked about. Authorization headers, cookies, tenant identifiers and user roles are not part of the key, so two users of the same page share one cached grant for the same URL. Any policy that varies per user or per plan is therefore served from a cache that cannot see the thing it varies on.
Is a short Max-Age enough to revoke access from an origin I just removed?
It shortens the window but it is not a revocation mechanism. A cached preflight only lets the browser skip the OPTIONS round trip; the actual request still reaches your server, which must re-check the origin and reject it. Treat Access-Control-Max-Age as an upper bound on how long a client keeps sending requests you will refuse, and enforce the removal on the actual response rather than relying on the cache expiring.
Should I just set Access-Control-Max-Age: 0 on a fast-changing API?
Only on the routes whose policy genuinely changes between requests, such as admin actions gated on a role that can be revoked mid-session. Zero means a preflight before every non-simple call, which doubles the request count and adds a full round trip to each write. Most fast-changing APIs are better served by a value between 10 and 60 seconds, which removes the burst of preflights during a page load while keeping the stale window inside a deploy cycle.