skip to content

Your API answers preflights with Access-Control-Max-Age: 600, yet OPTIONS requests keep appearing - what does that field cache?

level: middleimportance: should knowfreq 48%

answer

  1. delta-seconds, not milliseconds
  2. a browser structure, not an HTTP cache
  3. entries per method, per header name
  4. five seconds when the field is absent
  5. browsers clamp the value downward

basics

~20 s

Access-Control-Max-Age is a delta-seconds hint that populates the browser's own CORS-preflight cache. Entries are per method and per header name, not one per URL, so a new method or a newly named header preflights again.

solid answer

~40 s

`Access-Control-Max-Age` carries **delta-seconds** and feeds the browser's **CORS-preflight cache** - a browser-internal structure, not an HTTP cache. Its entries are keyed per method and per header name for that origin and URL, each carrying its own max-age, a credentials boolean and a network partition key. So a later call that uses a different method, or names a header the first call did not, misses and preflights again even though the URL is identical. When the field is absent or cannot be parsed, the value defaults to **5** seconds. Browsers also clamp the value downward to a limit they impose, so a large value buys the browser's maximum and no more.

code

http · 6 lines
http
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://tracing.example
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type,x-herd-consignment
Access-Control-Max-Age: 600
Vary: Origin

go deeper

for a junior

Recall that the field is spelled Access-Control-Max-Age, that its value is in seconds, and that it lets a browser skip asking again for a while rather than caching any data.

for a middle

Explain the entry structure: per method and per header name, with a credentials flag, inside a browser-side cache. That structure is what explains preflights you still see on a URL whose answer carries a large value.

for a senior

Bring the operational reading: the value is clamped downward by the browser, the default without the field is only 5 seconds, and reducing preflight volume is mostly about keeping the method and the header-name set stable across calls.

for a principal

The judgment worth showing is that this is a hint, not a contract - designing an integration so its round-trip cost does not depend on a number the client is free to shrink.

## What the field actually populates A traceability page calls one herd-registry URL many times a minute. The API answers each preflight with `Access-Control-Max-Age: 600`, and the operators still see `OPTIONS` requests in the log. Nothing is broken; the mental model is. `Access-Control-Max-Age` is a response field on the **preflight answer only**, and its value is **delta-seconds** - a count of seconds, not milliseconds, and not a timestamp. What it feeds is the browser's **CORS-preflight cache**. That cache is a structure inside the browser, distinct from an HTTP cache, and its entries are not URLs. ## The entry structure is the answer An entry records, for a given origin and URL: - **either a method or a header name** - never both in one entry, and never the whole request as a unit; - its own **max-age**, taken from the field on the answer that created it; - a **credentials boolean**, so an uncredentialed and a credentialed call do not share entries; - a **network partition key**, so entries do not travel between unrelated top-level browsing contexts. Before sending a preflight, the browser checks whether the method it is about to use and **every** CORS-unsafe header name it is about to disclose are already present as live entries. One missing entry is enough to send the preflight again. That is why `600` on the answer does not silence the log: 1. The first call is a `POST` naming one custom field - two entries are created. 2. A later call to the same URL uses `PATCH`. The header-name entry is still live; the method entry for `PATCH` does not exist. **A preflight is sent.** 3. A later call adds a second custom field. Both the method and the first name are cached; the new name is not. **A preflight is sent.** 4. A later call is made in a different credentials mode. The entries recorded under the other boolean do not apply. **A preflight is sent.** ## Not an HTTP cache, and why that distinction is a real question | | CORS-preflight cache | HTTP cache | |---|---|---| | Where it lives | inside the browser, for the CORS check | any client or shared cache on the path | | Keyed by | origin, URL, and one method or one header name | the request URL, refined by `Vary` | | Lifetime from | `Access-Control-Max-Age` | the response's own freshness fields | | Serves | the decision whether to ask again | the stored response itself | The distinction has teeth: RFC 9110 states that responses to `OPTIONS` are **not cacheable**, so a shared cache on the path is not supposed to be storing the preflight answer at all. The reuse you observe is a browser-side decision about whether to *ask*, not a stored response being *served*. ## The two numbers, and the one number you should not quote - **The default is 5 seconds.** When `Access-Control-Max-Age` is absent, or present but unparseable as delta-seconds, the value falls back to 5. Caching still happens - that is why a burst of calls inside a couple of seconds often shows one preflight and several plain requests even from a server that sends no such field at all. - **The ceiling is imposed by the browser, and the specification names no number.** The value is clamped downward to an implementation-defined limit. So `Access-Control-Max-Age: 86400` does not buy a day; it buys whatever that browser's maximum is. Quoting a specific cap in seconds as though it were specified is a factual error, and it differs between implementations anyway. ## How to read the field in practice Treat `Access-Control-Max-Age` as a **hint that reduces round trips**, never as a guarantee. Entries may be evicted at any time, the value is clamped, the default is only seconds long, and the keying means a client that varies its methods or its header names across calls will keep asking. If preflight volume genuinely matters, the lever is the **shape** of the calls - the same method and a stable set of header names against the same URL - at least as much as the number on the answer.

  • What happens when the answer to a preflight carries no Access-Control-Max-Age field at all?
    The value defaults to 5 seconds, and the same default applies when the field is present but cannot be parsed as delta-seconds. Caching still happens, so a burst of identical calls within those few seconds reuses the entries and sends one preflight; anything later asks again.
  • The same URL is called with PUT and then with PATCH, each naming the same one custom header field. What does the second call do?
    It preflights again. Entries are keyed per method as well as per header name, so the header-name entry from the first call is reusable but the method entry for `PATCH` does not exist. A single missing entry forces the whole preflight, and it will create the `PATCH` entry when it succeeds.
  • Can a very large Access-Control-Max-Age value raise the ceiling?
    No. Browsers clamp the value downward to a limit they impose, and the specification says such a limit exists without fixing a number. A large value therefore buys that browser's maximum and nothing beyond it, which is why the field is a hint about round trips rather than a contract you can plan against.

It is a note the doorman keeps for himself, not a pass he hands out: it says how long he may wave through that one kind of delivery with those labels without checking again - and a different kind of delivery sends him back to the desk.

saying these in an interview costs you the question

  • Thinks Max-Age caches the response to the real request
  • Says a shared cache on the path stores the preflight answer
  • Believes one cache entry covers the whole URL
  • Assumes the browser honours whatever value you send
  • Reads the delta-seconds value as milliseconds
  • Thinks a missing Max-Age means no caching at all