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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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)
Section titled “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)
Section titled “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
Section titled “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
Section titled “Checking your query’s cache status”Two response headers tell you about caching behaviour:
Surrogate-Control
Section titled “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
Section titled “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
Section titled “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