Skip to content

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

AttributeDescriptionType
maxAgeTTL for the cached response, in secondsInteger
swr (stale-while-revalidate)How long after expiry a stale response can be served while fresh data is fetchedInteger
sie (stale-if-error)How long after expiry a stale response can be served if the origin returns an errorInteger
scopePUBLIC (shared cache) or PRIVATE (per-user cache)Enum
noStoreIf true, the response must not be cachedBoolean

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: PRIVATE wins over PUBLIC
  • swr and sie: the lowest value is used
  • noStore: if any field is noStore: 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:

  1. The operation is a mutation (only queries can be cached)
  2. Edge caching is disabled for the API type (app or web) in site configuration
  3. The queried fields have no @surrogateControl annotation
  4. 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 edge
query ProductData($sku: SKU!) {
product(sku: $sku, strict: true) {
title
images { original }
}
}
# Query 2: PRIVATE - per-user, not cached
query 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:

  • inWishlist on Product and ProductVariant
  • recommendations on Product
  • sponsoredAds on Product, ProductVariant, and Basket

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 — cacheable
  • no-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 cache
  • MISS — 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:

  1. The first layer (e.g. WAF or clustering)
  2. The shield POP (a central cache shared across edge POPs)
  3. The edge POP (closest to the end user)

For example:

  • MISS, MISS, MISS — cache miss at every layer; the request reached Horizon
  • MISS, MISS, HIT — served from the edge POP cache; did not reach shield or origin
  • MISS, HIT, MISS — served from the shield POP cache; did not reach origin
  • HIT, 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