Edge Caching
Horizon supports edge caching via the Surrogate-Control HTTP response header. When enabled, responses to GraphQL queries can be cached at the CDN layer, significantly reducing latency and load on Horizon.
The @surrogateControl directive
Horizon’s schema uses the @surrogateControl directive on field definitions to declare caching behaviour. This directive controls the Surrogate-Control header in the HTTP response.
Attributes
| Attribute | Description | Type |
|---|---|---|
maxAge | TTL for the cached response, in seconds | Integer |
swr (stale-while-revalidate) | How long after expiry a stale response can be served while fresh data is fetched | Integer |
sie (stale-if-error) | How long after expiry a stale response can be served if the origin returns an error | Integer |
scope | PUBLIC (shared cache) or PRIVATE (per-user cache) | Enum |
noStore | If true, the response must not be cached | Boolean |
Example annotations
A publicly cacheable field with a 5-minute TTL:
type Query { page(path: PagePath!): Page @surrogateControl(maxAge: 300, swr: 60, sie: 600, scope: PUBLIC)}A per-user field that must not be cached:
type Product { inWishlist: Boolean @surrogateControl(noStore: true)}Scope and directive merging
When a query touches multiple fields with @surrogateControl annotations, the directives are merged, always taking the most restrictive value:
scope:PRIVATEwins overPUBLICswrandsie: the lowest value is usednoStore: if any field isnoStore: true, the entire response is not cached
Example
Consider a query that touches two fields:
product:@surrogateControl(maxAge: 300, swr: 60, sie: 600, scope: PUBLIC)inWishlist:@surrogateControl(noStore: true)
Because inWishlist is noStore: true, the merged result for the entire response is no-store. The product data cannot be edge-cached, even though on its own it would be cacheable for 5 minutes.
When no-store is applied automatically
A response will be marked no-store (not cached) if any of these conditions apply:
- The operation is a mutation (only queries can be cached)
- Edge caching is disabled for the API type (app or web) in site configuration
- The queried fields have no
@surrogateControlannotation - The response contains errors
Writing cache-friendly queries
To maximise cache hit rates, it is strongly advised to separate public and private data into different queries. Mixing fields with scope: PUBLIC and fields with noStore: true (or scope: PRIVATE) in a single query will prevent the entire response from being edge-cached, which increases latency and load on Horizon.
Bad: mixed query (not cacheable)
This query fetches product data (publicly cacheable) alongside inWishlist (per-user), so the entire response becomes no-store:
query ProductPage($sku: SKU!) { product(sku: $sku, strict: true) { title images { original } inWishlist # noStore: true - makes the whole response uncacheable recommendations { # noStore: true title } }}Good: separated queries (product data is cacheable)
Split into two queries. The first can be served from the edge cache:
# Query 1: PUBLIC - cacheable at the edgequery ProductData($sku: SKU!) { product(sku: $sku, strict: true) { title images { original } }}
# Query 2: PRIVATE - per-user, not cachedquery ProductUserData($sku: SKU!) { product(sku: $sku, strict: true) { inWishlist recommendations { title } }}The same principle applies to any query type. For example, basket queries that include product recommendations should separate the recommendation data if the basket itself needs to remain uncached.
Fields to watch for
The following commonly-used fields are annotated as noStore or private. Including any of these in a query alongside public fields will prevent the entire response from being edge-cached:
inWishlistonProductandProductVariantrecommendationsonProductsponsoredAdsonProduct,ProductVariant, andBasket
Checking your query’s cache status
Two response headers tell you about caching behaviour:
Surrogate-Control
This header tells you whether the response is eligible for edge caching:
max-age=300, stale-while-revalidate=60, stale-if-error=600, public— cacheableno-store— not cacheable
If you expect a query to be cacheable but see no-store, check whether any requested field has a noStore or PRIVATE annotation in the schema.
X-Cache
This header tells you whether the response was actually served from cache. It contains one or more comma-separated values:
HIT— the response was served from the edge cacheMISS— the response was fetched from the origin (Horizon)
Each comma-separated value represents a layer of the CDN that the request passed through. You will typically see three values, read left to right:
- The first layer (e.g. WAF or clustering)
- The shield POP (a central cache shared across edge POPs)
- The edge POP (closest to the end user)
For example:
MISS, MISS, MISS— cache miss at every layer; the request reached HorizonMISS, MISS, HIT— served from the edge POP cache; did not reach shield or originMISS, HIT, MISS— served from the shield POP cache; did not reach originHIT, HIT, HIT— served from cache at every layer; zero load on Horizon
Any value other than MISS at a given layer means that layer served the response from its cache rather than forwarding the request further. If the rightmost value (edge) is HIT, the end user got a cached response with minimal latency. If the middle value (shield) is HIT, the edge didn’t have it cached but the shield did, still avoiding a request to Horizon.
If Surrogate-Control shows no-store but you see X-Cache: MISS, MISS, MISS, that is expected — the CDN correctly forwarded the request to Horizon because the response is not cacheable.
Further reading
- Fastly: X-Cache header reference — details on interpreting cache hit/miss values
- Fastly: Shielding concepts and debugging — understanding multi-layer caching and how to debug cache behaviour