Persisted Queries (APQ)¶
Send a hash of the query instead of its text. Generated clients speak the Apollo Automatic Persisted Queries protocol, so they work with Apollo Server and any server that implements it, with no build step and no manifest.
It is available for Dart, Java (blocking, Reactor, RxJava3, Mutiny), Kotlin, TypeScript, Swift, Python and Go, and is off by default.
Enable it¶
{
"clientConfig": {
"typescript": {
"persistedQueries": "apq",
"persistedQueriesUseGet": true
}
}
}
| Option | Values | Default | Description |
|---|---|---|---|
persistedQueries |
false | "apq" |
false |
"apq" hashes long operations and retries on a miss. Any other value is a generation error. With false the generated output is unchanged. |
persistedQueriesUseGet |
boolean |
false |
Send hash-only queries with HTTP GET so CDNs and browsers can cache them. Requires persistedQueries: "apq"; setting it alone is an error. |
Both options exist under clientConfig.dart, java, typescript, kotlin, swift, python and go.
How it works¶
- The client computes the SHA-256 of the exact query string it is about to send, fragments included.
- It sends the operation name, variables and
extensions.persistedQuerywith the hash, and no query text. - If the server answers
PersistedQueryNotFound, the client sends the same request once more with the query text added. That second request makes the server register the query. - Every later call is hash-only again.
{
"operationName": "GetUser",
"variables": { "id": "1" },
"extensions": { "persistedQuery": { "version": 1, "sha256Hash": "<hash>" } }
}
The retry is invisible to your code: client.queries.getUser(...) returns the data as usual.
Rules worth knowing¶
- Short operations are not hashed. Below about 144 characters the hash payload is bigger than the query, so the normal request is sent. There is no option for this.
- The client keeps no registration state. Every call starts hash-only, so a server restart or cache eviction costs one extra request and nothing else.
- Retries are strict. The query text is re-sent only on
PersistedQueryNotFound. Timeouts, 5xx responses and other errors are never retried, so a mutation cannot run twice because of APQ. PersistedQueryNotSupportedmeans the server has APQ switched off. The client sends the full query and stops hashing for the rest of that client's life.- Mutations always use POST, hash-only first and then the retry. Subscriptions and file uploads are unchanged.
@glCachepartial queries: the merged query that is actually sent is hashed as a whole.@glCaptureErrors: aPersistedQueryNotFoundon the first attempt is never surfaced as a captured error.
GET for queries¶
With persistedQueriesUseGet, hash-only queries go out as
Variables and extensions are JSON in the URL and are percent-encoded by the generated adapter. The retry that registers the query is always a POST, because a full query may not fit in a URL.
If the server refuses GET (typically a 405), the GET adapter throws GraphLinkGetNotSupported. The client then sends that request as a POST and uses POST from then on. Any other error from the GET adapter propagates unchanged, because sending the request twice would hide real network failures.
Adapters¶
The POST adapter is unchanged, but it now receives payloads without a query field for persisted operations. An adapter that forwards the body as-is keeps working; one that reads query must be updated.
With persistedQueriesUseGet the client also needs a GET adapter with the same signature as the POST adapter. It receives the JSON payload and turns operationName, variables and extensions into the query string.
| Language | Default GET adapter | Custom GET adapter |
|---|---|---|
| Dart | GraphLinkClient.withHttp(...) (http) or the Dio adapter |
getAdapter: constructor argument, right after adapter |
| TypeScript | createFetchGetAdapter(url) / createAxiosGetAdapter(url) |
second constructor argument |
| Kotlin | DefaultGraphLinkGetAdapter (OkHttp) |
getAdapter argument, right after adapter |
| Java | DefaultGraphLinkGetAdapter for the JDK and OkHttp transports; the convenience constructor wires it |
getAdapter argument, right after adapter |
| Swift | DefaultGraphLinkURLSessionGetAdapter |
getAdapter: argument, right after adapter: |
| Python | GraphLinkHttpxAdapter.get_call (wired by GraphLinkClient.with_http) |
get_adapter= keyword argument |
| Go | GraphLinkHttpAdapter.GetCall (wired by NewGraphLinkClientWithHttp) |
GetAdapter field of GraphLinkClientOptions |
If your adapter cannot do GET, throw GraphLinkGetNotSupported from it. If you use the convenience constructors (withHttp and friends) both adapters are wired for you.
Server side¶
The server must implement APQ: look up the hash, run the stored query, and register a query when it receives text with a matching hash.
Apollo Server / Express supports APQ out of the box. Keep its cache bounded.
Spring for GraphQL has no turnkey support. A hash-only request arrives as a placeholder document named PersistedQueryMarker and fails with a syntax error, so you need a WebGraphQlInterceptor that:
- reads
extensions.persistedQuery.sha256Hash; - for a hash-only request, looks the hash up and replaces the execution input's query, or answers
PersistedQueryNotFound(extensions.code=PERSISTED_QUERY_NOT_FOUND); - for a request with text and hash, checks that SHA-256 of the text equals the hash, stores it, and continues.
Spring GraphQL only accepts POST on /graphql. To use persistedQueriesUseGet you also need a filter that turns GET /graphql?... into the POST the handler understands, and you should reject mutations over GET with 405. A reference implementation lives in integration_tests/persisted_queries/real/spring_persisted_queries_server in the GraphLink repository.
Bound the server cache
Any anonymous client can register any query. An unbounded store is a memory-exhaustion risk, so use a size-limited (LRU) cache with a TTL, and consider registering only for authenticated or allow-listed callers. The test server in the repository uses an unbounded map and is not meant for production.
Not included yet¶
- Manifest / safelist mode, where the server rejects unknown queries and every operation is registered at build time.
- Subscriptions over WebSocket.
Related¶
- Configuration — every client option
- Caching —
@glCacheand how it combines with hashing