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-Controlresponse 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
@cacheControlannotations. 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.
Ways to control this
Section titled “Ways to control this”| Through | What it controls |
|---|---|
| Your origin | max-age, s-maxage, stale-, no-store and no-cache in the Cache- response header, for plain HTTP and GraphQL. |
| Your GraphQL schema | maxAge and swr on @cacheControl, when your origin’s header says nothing about lifetime. |
gpilot.toml | Whether a header without a lifetime yields to the schema (cache.), the Cache- your clients receive (cache.), and how old an entry one client accepts (client profile max_age). |
| The API, the CLI and the portal | No 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:
- The origin sent a
Cache-Controlheader that constrains storage or freshness. The header decides: itsmax-ageors-maxage, itsstale-while-revalidate,no-storeandno-cache. The schema’smaxAgeandswrare not consulted. The schema’s scope still partitions the entry: a header says how long, a scope says for whom, so aPRIVATEor named scope on the selected fields keeps its discriminators. - The origin sent a header that constrains neither. A header made only of
private,publicorno-transformsays nothing about how long, so it yields and the schema’s policy decides. Anyprivateit 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). - The origin sent no
Cache-Controlat all. The schema’s@cacheControlpolicy decides. - Neither says anything. The response is not cached (
CACHE_SKIPPED_NO_CACHE_POLICY).
- constrains storage or freshnessThe origin header decidesthe schema's scope still partitions
- only private, public or no-transformSchema policy for this query?
- yesThe schema decidesa private is carried over
- noNot cachedCACHE_SKIPPED_NO_MAX_AGE
- noneSchema policy for this query?
- yesThe schema decides
- noNot cachedCACHE_SKIPPED_NO_CACHE_POLICY
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.
Lifetimes in one catalog
Section titled “Lifetimes in one catalog”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 | @cacheControl | Why |
|---|---|---|
Category | maxAge: 86400, swr: 604800 | Changes rarely, and a rename runs through renameCategory, a mutation that purges it |
Product | maxAge: 3600, swr: 3600 | A price changes through setPrice, which purges the product, so the hour rarely decides how old a price is |
Product. | maxAge: 5 | Stock moves with every order |
Me | maxAge: 60, scope: PRIVATE | One 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:
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.
When the lifetime runs out
Section titled “When the lifetime runs out”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.
Expiry is not your invalidation strategy
Section titled “Expiry is not your invalidation strategy”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:
- Automatic invalidation purges what a GraphQL mutation touched.
- Surrogate keys let a purge name what changed.
- The purge API covers changes that never went through GraphPilot, such as a database write or a background job.
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.
Changing a lifetime
Section titled “Changing a lifetime”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.
Related
Section titled “Related”- From the origin header: every
Cache-Controldirective - From the schema:
@cacheControlfor GraphQL - Stale windows: serving past the lifetime while refreshing
- Downstream header: what the client is told
- When a response is cached: the whole store decision