Skip to content

Lifetime from the origin header

Your origin’s Cache-Control response header is how any origin, plain HTTP or GraphQL, tells GraphPilot how long a response may be kept. For a plain HTTP response it is the only source: no header, or a header without a lifetime, means the response is not cached. For a GraphQL response it outranks your schema whenever it says anything about storage or freshness; see precedence.

Cache-Control: max-age=30, s-maxage=600, stale-while-revalidate=60
DirectiveWhat GraphPilot does
max-age=<s>The lifetime, unless s-maxage is also present.
s-maxage=<s>The lifetime at the edge. Overrides max-age, which is left to the browser. Also permission to share a response to a signed-in caller.
publicPermission to share a response to a signed-in caller. States no lifetime on its own.
privateThe entry is partitioned per caller under your cache.policy.private expression. Not stored if that does not resolve for the request (CACHE_SKIPPED_PRIVATE_WITHOUT_DISCRIMINATOR).
no-storeNot stored (CACHE_SKIPPED_NO_STORE).
no-cacheNot stored (CACHE_SKIPPED_NO_CACHE_DIRECTIVE). See below.
stale-while-revalidate=<s>A stale window after the lifetime.
must-revalidate, proxy-revalidateThe stale window is dropped: the entry is stored for its lifetime only (CACHE_STORED_WITHOUT_STALE_WINDOW in your logs). Not accepted as permission to share.
stale-if-error=<s>Parsed, and not acted on. GraphPilot does not serve a stale entry when your origin fails.
immutable, must-understandParsed. They change nothing beyond making the header one that decides.
no-transformParsed. States no lifetime, so a header made only of it yields to the schema.

Directives are matched case-insensitively, a header sent several times is read as one list, and a directive GraphPilot does not know is ignored, as RFC 9111 asks.

When the header decides, it is checked in this order, and the first rule that refuses ends it:

  1. no-store refuses.
  2. no-cache refuses.
  3. The lifetime comes from s-maxage, otherwise max-age. With neither, the response is refused as CACHE_SKIPPED_NO_MAX_AGE. This comes before the partitioning check, so a private response with no max-age reports CACHE_SKIPPED_NO_MAX_AGE.
  4. Every discriminator the entry is keyed on has to be available: your cache.policy.private value for a private response, and the names of any scope your schema set for the same query. See who shares an entry.
  5. A response to a request that carried a credential is shared only when the header says public or s-maxage, or names the credential in Vary. Otherwise it is partitioned or not stored. See authenticated requests.

The CACHE_POLICY_ORIGIN_HEADER log line records that the header decided, carries it verbatim as cache_control, and reports overrode_schema_policy = true when there was a schema policy it outranked.

s-maxage: a different lifetime at the edge than in the browser

Section titled “s-maxage: a different lifetime at the edge than in the browser”

max-age is addressed to every cache, the browser’s included. s-maxage is addressed to shared caches only, and GraphPilot is one. Where both are present, s-maxage decides how long the edge keeps the response and max-age is left to the browser, per RFC 9111.

The right answer is rarely the same number for both:

Cache-Control: max-age=30, s-maxage=600

Ten minutes at the edge, thirty seconds in the browser. The edge is one cache serving everybody, so a long lifetime there absorbs a lot of origin traffic, and you can purge it the moment the data changes. A browser cache is one per person, you cannot reach it, and nothing you do invalidates it before it expires.

Reversed, it says “cache this for the user, not for everyone”:

Cache-Control: max-age=600, s-maxage=0

s-maxage=0 keeps a response from ever being served fresh from the edge. If you mean that the edge should keep nothing at all, send no-store: s-maxage=0 next to a stale-while-revalidate is still served stale.

no-cache permits storing a response but forbids serving it without revalidating with the origin first, on every use. GraphPilot never sends a conditional request and never handles a 304, so an entry it stored could only be served in breach of that instruction. It does not store one.

Use max-age if you want the response cached, and no-store if you want it neither stored nor served from a cache.

GraphPilot reads exactly one response header to decide a lifetime: Cache-Control.

  • Surrogate-Control is ignored. If you come from a CDN that reads it, move its max-age into Cache-Control as s-maxage, which says the same thing to every shared cache.
  • Expires is ignored. State the lifetime with max-age or s-maxage.
  • Age, Date, ETag and Last-Modified are not consulted either. An entry’s age comes from the cache itself.

Which responses are refused whatever their Cache-Control says (Set-Cookie, Vary: * and others) is on when a response is cached.

Apollo Server’s cache-control plugin computes a Cache-Control header from the same @cacheControl annotations GraphPilot reads, and sends it on every response: max-age=N, public, max-age=N, private, or no-store for an operation it considers uncacheable. Because an origin header wins, GraphPilot then reads your schema hints and never applies them: the header your server already sends is authoritative.

That changes nothing when the header is what you want. If you would rather GraphPilot decide from the schema, stop Apollo Server from sending the header; see Apollo Server.