Skip to content

How long an entry lives (TTL)

Every entry GraphPilot stores has a freshness lifetime (TTL). While it lasts, the entry is served without asking your origin. When it runs out, the entry stops being served, whether or not anything invalidated it.

The lifetime comes from one of two sources:

  • Your origin’s Cache-Control response header. Works for every response, plain HTTP and GraphQL alike, and is the only source for a plain HTTP response. See from the origin header.
  • Your schema’s @cacheControl annotations. Only for a GraphQL operation, and only when the origin header says nothing about lifetime. See from the schema.

How long is one of the two questions a cache entry answers. The other, who else may be served it, is partitioning.

ThroughWhat it controls
Your originmax-age, s-maxage, stale-while-revalidate, no-store and no-cache in the Cache-Control response header, for plain HTTP and GraphQL.
Your GraphQL schemamaxAge and swr on @cacheControl, when your origin’s header says nothing about lifetime.
gpilot.tomlWhether a header without a lifetime yields to the schema (cache.ignore_origin_header_without_freshness), the Cache-Control your clients receive (cache.policy.downstream), and how old an entry one client accepts (client profile max_age).
The API, the CLI and the portalNo lifetime setting. To end an entry early, purge it.

Precedence: the origin header decides if it says anything

Section titled “Precedence: the origin header decides if it says anything”

GraphPilot decides a response’s lifetime in this order:

  1. The origin sent a Cache-Control header that constrains storage or freshness. The header decides: its max-age or s-maxage, its stale-while-revalidate, no-store and no-cache. The schema’s maxAge and swr are not consulted. The schema’s scope still partitions the entry: a header says how long, a scope says for whom, so a PRIVATE or named scope on the selected fields keeps its discriminators.
  2. The origin sent a header that constrains neither. A header made only of private, public or no-transform says nothing about how long, so it yields and the schema’s policy decides. Any private it stated is still carried over, so the entry stays partitioned. This applies only when the query selected an annotated field and so resolved a schema policy. Otherwise the bare header decides, and without a lifetime the response is not cached (CACHE_SKIPPED_NO_MAX_AGE).
  3. The origin sent no Cache-Control at all. The schema’s @cacheControl policy decides.
  4. Neither says anything. The response is not cached (CACHE_SKIPPED_NO_CACHE_POLICY).
The four rules above, as a decision.

Every directive the header can carry, and what each one does, is on from the origin header. How the schema’s maxAge resolves across a query is on from the schema.

Rule 2 exists because a bare Cache-Control: private is often infrastructure noise rather than a policy: several hosting platforms add it to every response. CACHE_POLICY_ORIGIN_HEADER_IGNORED in your logs says the header yielded and prints it verbatim. Set cache.ignore_origin_header_without_freshness = false to turn the yield off, so that the mere presence of an origin Cache-Control decides the response.

The lifetime an entry was stored with is on every hit as gp-cache-max-age, with gp-cache-age and gp-cache-remaining beside it.

A lifetime follows from how fast the data changes and what else ends it. The GraphPilot demo API, a product catalog, states these in its schema:

Data@cacheControlWhy
CategorymaxAge: 86400, swr: 604800Changes rarely, and a rename runs through renameCategory, a mutation that purges it
ProductmaxAge: 3600, swr: 3600A price changes through setPrice, which purges the product, so the hour rarely decides how old a price is
Product.inventorymaxAge: 5Stock moves with every order
MemaxAge: 60, scope: PRIVATEOne caller’s own profile

A response lives as long as the shortest-lived thing it selects. Ask for a product’s name and price and the entry is stored for an hour; add its stock and the same product is stored for five seconds:

Terminal window
curl -sS -D- -o/dev/null https://demo-api.graphpilot.cloud/graphql \
-H 'content-type: application/json' \
-d '{"query":"query ProductWithStock { product(id: \"p01\") { id name price inventory { available } } }"}' \
| grep -i '^gp-cache'

Run it twice within five seconds and the second response, a HIT, carries gp-cache-max-age: 5; without inventory it carries 3600. So a page that shows a product and its stock is cheaper as two queries, one stored for an hour and one for five seconds. The worked examples on the schema page go through each rule.

Without a stale window, the entry stops being served. The next request is a MISS, goes to your origin, and its response is stored as a new entry.

With a stale window, the expired entry keeps being served for that many seconds more, reported as STALE, while a background request refreshes it. Within an edge location, a request handled by a cache server that does not hold the entry can occasionally be a MISS instead. See stale responses within a location.

A lifetime answers “how long may this be wrong”, and every answer is a trade: short means a low hit rate and a busy origin, long means users see old data. You do not have to sit on that line. GraphPilot can remove an entry when the data actually changes, which lets the lifetime be long:

So the useful question is not “how fresh does this need to be” but “what could change this data without GraphPilot finding out, and how long can that be wrong for”. For data only ever written through requests that purge, that window is short by construction and the lifetime can be hours. For data a nightly import touches, it is the import.

A new lifetime, and any other policy change (a scope, a cache.policy expression), applies to responses stored after the change. Entries already in the cache keep the lifetime they were written with until they expire or are purged. If you shorten a lifetime because it was too long, purge the affected keys as well, or the old entries outlive the change by their own full lifetime.