Skip to Content
API ReferenceResponse Caching

Response Caching

The public GraphQL API caches resolved responses by default. The first request for a given query resolves normally; repeat requests for the same query within the cache lifetime return the previously-resolved bytes directly, skipping the resolver. Edits invalidate cached entries automatically.

This is a big perf win for read-heavy storefronts. A page query that takes 1+ second cold can return in well under 100ms warm — most of the warm-request time is network, not server work.

What’s cached

Every model with publicApi: true caches by default, with the following defaults:

Model kindDefault scopeDefault lifetime
StandardPUBLIC — one cached entry serves every caller with the same query60 seconds
customerScoped: truePRIVATE — entries are isolated per signed-in customer60 seconds

PUBLIC and PRIVATE refer to the cache scope, not the visibility of the data itself. A PUBLIC entry can be served to any caller whose query, variables, schema channel, and caller scopes match. A PRIVATE entry adds the customer id to the cache key so each customer gets isolated entries.

Per-customer models are forcibly downgraded to PRIVATE. If you configure a customerScoped: true model with cacheControl: { scope: 'PUBLIC' }, Foir overrides it to PRIVATE and logs a warning. This prevents one customer’s view from leaking to another via a shared cache entry.

Mutations are never cached. Draft-channel reads are never cached. Reads that pass preview: true are never cached.

Tuning a model

Open a model in the admin app and find the Caching sub-section on its Settings tab. The toggle controls whether responses are cached at all; the scope and lifetime inputs control the rest. customerScoped models lock the scope to “Per-customer”.

The same settings live in foir.config.ts under config.cacheControl. Either surface writes to the same place — the admin reflects what’s in foir.config.ts and vice versa.

{ key: 'page', name: 'Page', config: { publicApi: true, publishing: true, cacheControl: { maxAge: 60, scope: 'PUBLIC' }, }, // ... }

A 60-second default is short enough that staleness from an edit is bounded to one minute even if invalidation somehow misses; long enough to absorb realistic storefront traffic spikes. Slow-changing settings (navigation, UI copy, redirects) often warrant longer lifetimes — 300 seconds is common.

Opting out

Some responses can’t safely be cached. To opt a model out, set the scope to 'NO_CACHE':

{ key: 'live_dashboard', config: { publicApi: true, cacheControl: { scope: 'NO_CACHE' }, }, }

Toggling the Caching switch off in the admin app does the same thing.

When to opt out:

  • Real-time data — chat presence, live metrics, anything where the API consumer expects to see writes immediately.
  • Frequently-changing aggregates — leaderboards, counters, anything whose value depends on a write rate higher than your cache TTL.
  • Per-request side effects — queries that fire an analytics event or update a “last seen” field as part of resolution.

Opting out doesn’t free up TTL tuning later. If a model lives on NO_CACHE for a while and you decide its read rate justifies caching, you can flip it back on and the same defaults apply.

How invalidation works

When you edit a record — create, update, delete, publish, or unpublish — every cached response that referenced that record drops immediately. List responses drop when any record in the list is edited. Cached entries for one model don’t invalidate when an unrelated model is edited.

Invalidation is write-through: it happens as part of the same transaction that committed the edit. There’s no eventual-consistency lag and no background job. If the cache layer can’t talk to Redis for some reason, the edit still commits and the cache layer logs the failure; the next read will resolve normally and re-populate the cache.

The cache lifetime (TTL) is a safety net for the unlikely case where an invalidation gets lost — the entry expires on its own at most maxAge seconds after it was stored.

Per-request opt-outs

Storefronts can bypass the cache for a specific request using the standard HTTP Cache-Control request header:

HeaderBehaviour
Cache-Control: no-cacheSkip the cached entry, run the resolver fresh, and re-populate the cache with the new response. Useful for “read your own write” after a mutation.
Cache-Control: no-storeSkip the cache entirely — no read, no write. Useful for one-off debugging without polluting the shared cache.
# Force a fresh read; subsequent normal callers get the new entry curl -X POST https://api.foir.io/graphql \ -H "x-api-key: pk_live_..." \ -H "Cache-Control: no-cache" \ -d '{"query":"{ pages { id title } }"}'

These headers only affect the single request that carries them. They don’t change the model’s configured caching behaviour.

Cache headers on responses

Every cached response carries:

  • Cache-Control: public, max-age=<seconds> for PUBLIC entries
  • Cache-Control: private, max-age=<seconds> for PRIVATE entries
  • Surrogate-Key: <space-separated tags> for downstream CDN cache-tag purges

Downstream caches (browsers, CDNs) can honour Cache-Control to skip the round trip to Foir entirely. The CDN integration is on the roadmap and will let the same edits that invalidate Foir’s cache also purge the matching CDN entries.

Last updated on