CORS preflight caching: how Access-Control-Max-Age works
Every non-simple cross-origin request should trigger one preflight and then reuse that answer, not send a fresh OPTIONS before every single call. Access-Control-Max-Age is the header that's supposed to make that happen — but browsers cap it far below what most servers ask for, and it's easy to set it and still see an OPTIONS on every request anyway.
What a missed cache actually costs
A preflight is a full round trip before the request you actually wanted to send: browser opens the connection, sends OPTIONS, server answers, then the real GET or POST goes out. On a fast connection that's a rounding error. On a mobile network, or against an API with a few hundred milliseconds of latency, it roughly doubles the time to first byte for every call that needs one — and a JSON API sending an Authorization header needs one on every distinct request shape.
If your app makes the same kind of call over and over (same origin, same URL, same method, same headers), the browser only needs to ask once. Access-Control-Max-Age is how the server tells it how long "once" lasts.
The header, and its default
It's a response header on the OPTIONS reply, not the real response:
OPTIONS /v1/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
The value is seconds the browser may cache this exact preflight result before asking again. If the header is missing, the default is 5 seconds — so an API that never sets it is effectively preflighting almost every call anyway, since 5 seconds rarely spans two real requests from a user.
The cap every browser applies regardless
Set Access-Control-Max-Age: 86400 (24 hours) and most browsers will quietly clamp it. Per MDN's Access-Control-Max-Age reference, current Chromium (since Chrome 76) caps the effective value at 7200 seconds (2 hours) and Firefox caps it at 86400 seconds (24 hours) — so Firefox is the one browser that will actually honor a full day. Safari (WebKit) also applies its own, shorter cap; MDN doesn't document its value, so don't count on more than a few minutes there. None of them will tell you they clamped it; the response header you see is the one you sent, the cache lifetime you get is the browser's ceiling.
Practically: asking for anything above 7200 buys you nothing extra in Chrome, and Safari's cap is lower still.
What actually invalidates the cache
The cached answer is keyed to the requesting origin and the request URL. Within that, the browser also tracks which methods and headers the last preflight vetted. A new call to the same URL from the same origin can still skip preflight entirely — but only if its method and every one of its headers were already covered by the cached result. Add a header you didn't preflight for, switch from GET to DELETE, or hit a different path, and you get a fresh OPTIONS even with 7000 seconds left on the cache. This is why an app that "randomly" preflights every few calls is usually varying something in the request — an extra debug header, a conditionally-added Authorization — without realizing the cache key just changed.
While you're debugging CORS config, send Access-Control-Max-Age: 0 so every request gets a fresh preflight and a stale cached answer can't mask your edits. (MDN defines the value as a non-negative number of seconds, so don't rely on negative values.)
What corsproxy.dev sends
The proxy's own OPTIONS responses set Access-Control-Max-Age: 86400, matching Firefox's ceiling. In Chrome and Safari the browser shortens it, as above — there's nothing we or you can configure to get past those browser-side limits, since the cap isn't negotiable from the server. If you're fronting your own API and want to raise this, setting it to something in the 7200–86400 range is enough; going higher only helps Firefox and only up to a day.
Checking it's actually working
Open DevTools → Network, filter by method or by "Fetch/XHR", and make the same call twice in quick succession. The first pair should show an OPTIONS followed by the real request; the second should show only the real request. If you see an OPTIONS every time, check the response headers on it for Access-Control-Max-Age first — a missing header (5-second default) is the most common cause — then check whether the request you're repeating is actually identical in method and headers to the one that got cached.