
# GraphQL Cache Policy

This policy caches GraphQL query responses at the edge so identical queries are
served without a round-trip to your origin.

Unlike CDN caching that keys on the raw request body, this policy parses each
GraphQL document and normalizes it before building a cache key. Insignificant
whitespace, field formatting, and fragment layout are collapsed, and variable
object keys are sorted. As a result, two requests that are semantically
identical share a cache entry even when their bodies differ byte-for-byte — and
there is no query size or nesting-depth limit.

Only `query` operations are cached. Mutations, subscriptions, malformed
documents, and non-GraphQL bodies are always forwarded to the origin untouched.
To avoid serving one user's data to another, requests carrying an
`authorization` or `cookie` header are not cached unless you opt in with the
`cacheKeyHeaders` option.

## Configuration

The configuration shows how to configure the policy in the 'policies.json' document.

```json title="config/policies.json"
{
  "name": "my-graphql-cache-inbound-policy",
  "policyType": "graphql-cache-inbound",
  "handler": {
    "export": "GraphQLCacheInboundPolicy",
    "module": "$import(@zuplo/graphql)",
    "options": {
      "cacheKeyHeaders": ["authorization"],
      "cacheName": "graphql-responses",
      "ttlSeconds": 60
    }
  }
}
```

### Policy Configuration

- `name` <code className="text-green-600">&lt;string&gt;</code> - The name of your policy instance. This is used as a reference in your routes.
- `policyType` <code className="text-green-600">&lt;string&gt;</code> - The identifier of the policy. This is used by the Zuplo UI. Value should be `graphql-cache-inbound`.
- `handler.export` <code className="text-green-600">&lt;string&gt;</code> - The name of the exported type. Value should be `GraphQLCacheInboundPolicy`.
- `handler.module` <code className="text-green-600">&lt;string&gt;</code> - The module containing the policy. Value should be `$import(@zuplo/graphql)`.
- `handler.options` <code className="text-green-600">&lt;object&gt;</code> - The options for this policy. [See Policy Options](#policy-options) below.

### Policy Options

The options for this policy are specified below. All properties are optional unless specifically marked as required.

- `cacheName` <code className="text-green-600">&lt;string&gt;</code> - The name of the cache used to store responses. Routes that share a name share a cache; use distinct names to isolate caches per route or per upstream. Defaults to `"graphql-responses"`.
- `ttlSeconds` <code className="text-green-600">&lt;number&gt;</code> - How long, in seconds, a cached response is served before it is considered stale and the next request is forwarded to the origin to refresh the entry. Defaults to `60`.
- `cacheKeyHeaders` <code className="text-green-600">&lt;string[]&gt;</code> - Request header names whose values are included in the cache key (matched case-insensitively), and the control for how credentialed requests are cached. Omit this option (the default) and requests carrying an `authorization` or `cookie` header are not cached, to avoid serving one user's response to another. List those headers to cache such requests keyed per value, so each distinct value gets its own entry (a credential header you don't list still blocks caching, so a partial list fails safe). Set it to an empty array `[]` to cache a single response shared across all callers — only do this when the response does not depend on who is calling, as it disables the per-user safety check.

## Using the Policy

The GraphQL Cache policy stores successful GraphQL query responses in a
[ZoneCache](https://zuplo.com/docs/articles/zonecache) and serves later,
identical queries directly from the edge.

### How caching works

For every inbound request the policy:

1. Reads the request body and parses it as GraphQL JSON
   (`{ "query": "...", "variables": { ... }, "operationName": "..." }`).
2. Parses the query and re-prints it, producing a canonical form that ignores
   insignificant whitespace, field formatting, and fragment layout.
3. Canonicalizes the `variables` by recursively sorting object keys.
4. Hashes the normalized query, canonicalized variables, and `operationName`
   (SHA-256) into a cache key. `operationName` is included because a document
   with multiple operations returns a different response depending on which
   operation the client selects.

On a **hit**, the cached response is returned immediately. On a **miss**, the
request is forwarded to the origin and a successful response is stored for
future requests.

Every response served or stored by the policy carries two headers:

- **`x-cache`** — `HIT` when served from cache, `MISS` when fetched from the
  origin.
- **`x-cache-key`** — the first 8 characters of the cache key, useful for
  confirming that two requests resolve to the same entry.

Both headers are added to `access-control-expose-headers` so browsers can read
them, without overwriting any value an upstream CORS policy already set.

### What is and isn't cached

- Only `query` operations are cached. **Mutations** and **subscriptions** are
  forwarded to the origin and never cached. In a multi-operation document the
  operation selected by `operationName` is the one that decides this.
- **Malformed** GraphQL and **non-JSON** bodies are forwarded untouched so the
  origin can return a proper error. Documents with multiple operations but no
  `operationName` (or an `operationName` that matches none) are also forwarded.
- Only `200` responses are cached, and only when the body is a successful
  GraphQL result. Because GraphQL returns execution errors with a `200` status
  and an `errors` array, responses carrying a non-empty `errors` array — and
  non-JSON `200` bodies — are **not** cached.

### Options

- **`cacheName`** - The name of the cache used to store responses. Defaults to
  `graphql-responses`. Routes that share a name share a cache; use distinct
  names to isolate caches per route or per upstream.
- **`ttlSeconds`** - How long, in seconds, a cached response is served before it
  is considered stale and the next request is forwarded to the origin to refresh
  the entry. Defaults to `60`.
- **`cacheKeyHeaders`** - Request header names whose values are included in the
  cache key (matched case-insensitively), and the control for how credentialed
  requests are cached. See
  [Authentication and per-user caching](#authentication-and-per-user-caching)
  below. Defaults to omitted.

### Authentication and per-user caching

A response cache keyed only on the query would serve the first user's response
to everyone. To prevent that, the policy **does not cache requests that carry an
`authorization` or `cookie` header** by default — those requests are forwarded
to the origin every time.

To cache authenticated traffic safely, list the headers that make a response
user-specific in `cacheKeyHeaders`. Each listed header's value becomes part of
the cache key, so every distinct value (for example, every bearer token) gets
its own cache entry:

```json
{
  "name": "graphql-cache",
  "policyType": "graphql-cache-inbound",
  "handler": {
    "export": "GraphQLCacheInboundPolicy",
    "module": "$import(@zuplo/graphql)",
    "options": {
      "ttlSeconds": 30,
      "cacheKeyHeaders": ["authorization"]
    }
  }
}
```

If a request still carries an `authorization` or `cookie` header that is **not**
in `cacheKeyHeaders`, it is left uncached — so partially configuring the
allowlist fails safe rather than leaking across users.

#### Caching credentialed requests as a single shared entry

Sometimes a request must carry `authorization` (or `cookie`) to be authorized,
but the response is identical for everyone allowed through — the credential
gates access without changing the data. In that case, set `cacheKeyHeaders` to
an **empty array** to cache one response and share it across all callers:

```json
{
  "name": "graphql-cache",
  "policyType": "graphql-cache-inbound",
  "handler": {
    "export": "GraphQLCacheInboundPolicy",
    "module": "$import(@zuplo/graphql)",
    "options": {
      "cacheKeyHeaders": []
    }
  }
}
```

This is distinct from omitting the option: omitting it keeps the safe default
(credentialed requests are not cached), whereas `[]` is an explicit assertion
that the response does not depend on the caller. **Only use `[]` when that is
true** — otherwise one caller's response will be served to others.

Response cookies are never shared. `Set-Cookie` (along with `Set-Cookie2` and
`Clear-Site-Data`) is stripped from the stored entry, so a cookie an origin sets
on one caller's response is never replayed to another from cache. The caller
whose request reached the origin still receives the original `Set-Cookie`.

### Example

```json
{
  "name": "graphql-cache",
  "policyType": "graphql-cache-inbound",
  "handler": {
    "export": "GraphQLCacheInboundPolicy",
    "module": "$import(@zuplo/graphql)",
    "options": {
      "cacheName": "graphql-responses",
      "ttlSeconds": 60
    }
  }
}
```

Read more about [how policies work](/articles/policies)
