GraphQL als GET-Request cachefähig machen
GraphQL-Requests laufen standardmäßig als POST mit Query-Text im Body, und POST-Antworten cacht kein CDN. Automatic Persisted Queries lösen dieses Problem, indem sie die Query durch einen stabilen SHA256-Hash ersetzen und den Request als GET übertragen. Damit werden CDN, Browser-Cache und Edge-Layer plötzlich nutzbar, ohne dass Frontend-Code die eigentliche Query-Logik verliert.
Inhaltsverzeichnis
- 1. Warum POST-basiertes GraphQL kein CDN erreicht
- 2. Das APQ-Protokoll: Hash-Handshake in zwei Schritten
- 3. Client-Implementierung mit Apollo Client
- 4. Server-seitiger Persisted-Query-Cache
- 5. Vom Hash zum GET-Request: die Voraussetzung fürs CDN
- 6. CDN-Konfiguration: Cache-Keys, Vary und TTL
- 7. Invalidierung bei Deploys und Mutations
- 8. Sicherheit: Persisted-Query-Only-Mode und Denylisting
- 9. Automatic Persisted Queries im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum POST-basiertes GraphQL kein CDN erreicht
Ein klassischer GraphQL-Request sendet den kompletten Query-Text als JSON-Body per POST an einen einzigen Endpunkt, meist /graphql. Für HTTP-Caching ist das ungünstig gleich in zweifacher Hinsicht: POST-Antworten werden von Browsern, CDNs und Reverse-Proxies standardmäßig nicht gecacht, und selbst wenn man das erzwingen wollte, fehlt ein stabiler Cache-Key, weil sich der Query-Text zwischen Builds, Formatierungen und Whitespace-Varianten unterscheiden kann. Zwei inhaltlich identische Queries mit unterschiedlicher Einrückung erzeugen unterschiedliche Cache-Einträge, obwohl sie dieselbe Antwort liefern würden.
Für REST-APIs ist Edge-Caching selbstverständlich: eine GET-Anfrage an /api/products/42 hat eine feste URL, die ein CDN als Cache-Key nutzen kann. GraphQL bricht dieses Modell, weil dieselbe URL potenziell hunderte verschiedene Queries beantwortet. Genau hier setzen Automatic Persisted Queries an: Sie geben jeder Query eine deterministische, kurze Identität, die sich wie eine REST-URL behandeln lässt. Ohne dieses Pattern bleibt GraphQL bei großen, öffentlichen Katalogseiten oder Content-APIs strukturell im Nachteil gegenüber REST.
Der Leidensdruck wächst mit der Traffic-Menge: Ein Onlineshop, der Produktdaten über GraphQL an tausende gleichzeitige Besucher ausliefert, trifft bei jeder Anfrage erneut den Origin-Server, weil kein Zwischenspeicher greifen kann. Automatic Persisted Queries verschieben diese Last dorthin, wo sie hingehört: an den Edge, nah am Nutzer, Millisekunden statt hunderte Millisekunden entfernt.
2. Das APQ-Protokoll: Hash-Handshake in zwei Schritten
Automatic Persisted Queries funktionieren nach einem einfachen, von Apollo standardisierten Protokoll. Der Client berechnet einen SHA256-Hash über den exakten Query-String und sendet im ersten Versuch nur diesen Hash, ohne den vollständigen Query-Text. Kennt der Server den Hash noch nicht, antwortet er mit dem Fehlercode PersistedQueryNotFound. Der Client registriert diese Antwort und sendet in einem zweiten Request Hash und vollständige Query gemeinsam, woraufhin der Server beides im Persisted-Query-Store ablegt, meist Redis oder ein anderer verteilter Key-Value-Store.
Alle nachfolgenden Requests, auch von anderen Clients, müssen nur noch den Hash übertragen. Das reduziert nicht nur die Payload-Größe erheblich, sondern schafft genau die stabile Identität, die für Caching benötigt wird: derselbe Hash bedeutet immer dieselbe Query, unabhängig von Formatierung oder Client-Version. Automatic Persisted Queries unterscheiden sich damit von statisch generierten Persisted-Query-Manifesten, die beim Build vorab erzeugt werden, dadurch, dass der Handshake zur Laufzeit passiert und keine zusätzliche Build-Pipeline benötigt wird.
# Original query, hashed with SHA256 for Automatic Persisted Queries
query GetProductBySku($sku: String!) {
product(sku: $sku) {
id
name
price {
regularPrice { amount { value currency } }
}
media {
url
altText
}
}
}
# sha256Hash: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85
// Step 1 — client sends only the hash, no query text
POST /graphql
{
"operationName": "GetProductBySku",
"variables": { "sku": "MS-1042" },
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85"
}
}
}
// Server response — hash unknown, ask for the full query
{
"errors": [{ "message": "PersistedQueryNotFound" }]
}
// Step 2 — client retries, this time with hash AND query
POST /graphql
{
"operationName": "GetProductBySku",
"variables": { "sku": "MS-1042" },
"query": "query GetProductBySku($sku: String!) { product(sku: $sku) { id name } }",
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85"
}
}
}
3. Client-Implementierung mit Apollo Client
Apollo Client bringt die Referenzimplementierung für Automatic Persisted Queries direkt als Link mit. Der createPersistedQueryLink übernimmt Hashing, den zweistufigen Handshake und das automatische Retry bei PersistedQueryNotFound, ohne dass Anwendungscode davon etwas mitbekommt. Entscheidend ist die Kombination mit createHttpLink, die festlegt, ob der zweite, erfolgreiche Request tatsächlich als GET gesendet wird, denn genau dieser GET-Request ist die Voraussetzung für CDN-Caching.
In der Praxis reicht es, den Persisted-Query-Link vor den HTTP-Link in die Apollo-Link-Chain zu hängen. Für reine Lesezugriffe, etwa Produktabfragen in einem Onlineshop, lohnt sich zusätzlich eine Größenbeschränkung: Sehr lange GET-URLs stoßen bei manchen Proxys an Limits, weshalb Apollo Client automatisch auf POST zurückfällt, falls die resultierende URL eine konfigurierbare Länge überschreitet. Diese Rückfallebene ist wichtig, damit Automatic Persisted Queries nicht an Edge-Cases wie sehr komplexen, verschachtelten Queries scheitern.
// apollo-client.js — Automatic Persisted Queries with GET for CDN caching
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { createHttpLink } from "@apollo/client/link/http";
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";
// GET only for the successful, hash-only request — this is what CDNs can cache
const httpLink = createHttpLink({
uri: "https://shop.mironsoft.de/graphql",
useGETForHashedQueries: true,
});
const persistedQueryLink = createPersistedQueryLink({ sha256 });
export const client = new ApolloClient({
link: persistedQueryLink.concat(httpLink),
cache: new InMemoryCache(),
});
4. Server-seitiger Persisted-Query-Cache
Auf Serverseite braucht Automatic Persisted Queries einen Store, der Hash-zu-Query-Zuordnungen über Requests und Server-Instanzen hinweg verfügbar hält. Apollo Server unterstützt das über einen Cache-Adapter, üblicherweise Redis, damit alle Instanzen hinter einem Load Balancer denselben Hash kennen und nicht jede Instanz einzeln den PersistedQueryNotFound-Handshake durchlaufen muss. Ohne geteilten Cache würde bei jedem Instanzwechsel der zweistufige Handshake erneut ausgelöst, was den Performance-Vorteil zunichtemacht.
Ein sinnvoller TTL für den Persisted-Query-Store liegt deutlich über der Cache-Zeit der eigentlichen Antworten, oft mehrere Wochen, weil sich Query-Strukturen seltener ändern als die zugrunde liegenden Daten. Wichtig ist die Trennung zweier Caches: der Persisted-Query-Store hält die Zuordnung Hash zu Query-Text, während ein separater Response-Cache oder eben das CDN die tatsächlichen JSON-Antworten hält. Beide zusammen ergeben erst den vollen Nutzen von Automatic Persisted Queries.
// server.js — Apollo Server with a shared Redis-backed persisted query cache
import { ApolloServer } from "@apollo/server";
import { KeyvAdapter } from "@apollo/utils.keyvadapter";
import Keyv from "keyv";
const server = new ApolloServer({
typeDefs,
resolvers,
// Shared across all instances behind the load balancer
persistedQueries: {
cache: new KeyvAdapter(new Keyv("redis://redis:6379")),
ttl: 60 * 60 * 24 * 30, // 30 days — query shapes rarely change
},
});
5. Vom Hash zum GET-Request: die Voraussetzung fürs CDN
Der eigentliche Caching-Gewinn entsteht erst, wenn der zweite, erfolgreiche Request als GET übertragen wird. Der Hash und die Variablen wandern dabei als Query-Parameter in die URL, zum Beispiel /graphql?extensions={"persistedQuery":{"sha256Hash":"…"}}&variables={"sku":"MS-1042"}. Diese URL ist deterministisch: gleicher Hash, gleiche Variablen, gleiche URL, gleicher Cache-Eintrag. Genau das unterscheidet Automatic Persisted Queries von normalem POST-GraphQL, bei dem jede Anfrage aus Sicht des HTTP-Caching einzigartig aussieht.
Wichtig ist, dass wirklich nur Lesezugriffe über GET laufen. Mutations müssen zwingend als POST bleiben, da GET-Requests laut HTTP-Semantik idempotent und ohne Seiteneffekte sein sollen. Apollo Client trennt das automatisch: Nur Queries nutzen useGETForHashedQueries, Mutations werden davon nicht beeinflusst. Wer eigene Clients baut, etwa für mobile Apps ohne Apollo, muss diese Trennung manuell nachbilden, sonst drohen ungewollt gecachte Schreiboperationen.
6. CDN-Konfiguration: Cache-Keys, Vary und TTL
Am CDN, ob Fastly, Cloudflare oder ein klassischer Varnish-Layer, braucht die Konfiguration drei Dinge: einen Cache-Key, der Hash und Variablen einschließt, korrekte Vary-Header für Locale- oder Store-abhängige Antworten, und eine bewusst gewählte TTL. Der Origin-Server muss dafür passende Cache-Control-Header setzen, etwa Cache-Control: public, max-age=300, s-maxage=3600, wobei s-maxage speziell die Edge-Cache-Zeit steuert und unabhängig von der Browser-Cache-Zeit ist.
Bei mehrsprachigen oder Multi-Store-Setups, wie sie in Magento häufig vorkommen, muss der Cache-Key zusätzlich Store-Kontext berücksichtigen, sonst liefert das CDN deutsche Preise an einen US-Store aus. Ein bewährtes Pattern ist, den Store-Header explizit in den Vary-Header aufzunehmen und ihn als Teil des Cache-Keys zu behandeln, statt sich allein auf Cookies zu verlassen, die viele CDNs standardmäßig aus dem Cache-Key ausschließen.
# Inspect cache behavior for an Automatic Persisted Queries GET request
curl -I "https://shop.mironsoft.de/graphql?extensions=%7B%22persistedQuery%22%3A%7B%22sha256Hash%22%3A%22e3b0c4%22%7D%7D&variables=%7B%22sku%22%3A%22MS-1042%22%7D" \
-H "Store: de_DE"
# Expected headers on a cache HIT
# HTTP/2 200
# cache-control: public, max-age=300, s-maxage=3600
# vary: Store, Accept-Language
# x-cache: HIT
# age: 118
# Origin (Apollo Server / Express) sets the header per response
# res.set("Cache-Control", "public, max-age=300, s-maxage=3600");
7. Invalidierung bei Deploys und Mutations
Cache-Invalidierung ist bei Automatic Persisted Queries in zwei Fällen relevant: Ändert sich die Query selbst, etwa durch ein neues Frontend-Release, entsteht automatisch ein neuer Hash und damit ein neuer Cache-Eintrag, alte Einträge laufen einfach per TTL aus. Kritischer ist der Fall, dass sich die zugrunde liegenden Daten ändern, während der Hash gleich bleibt, zum Beispiel wenn ein Produktpreis aktualisiert wird. Hier hilft reines TTL-basiertes Caching nur bedingt, weil zwischen Preisänderung und Ablauf der TTL veraltete Daten ausgeliefert werden.
Die robuste Lösung ist Surrogate-Key-basierte Invalidierung, wie sie Fastly und Varnish anbieten: Der Origin-Server sendet einen zusätzlichen Header, etwa Surrogate-Key: product-1042, und beim Speichern eines Produkts wird gezielt per API-Call genau dieser Surrogate-Key am CDN gepurged, ohne den gesamten Cache zu leeren. Für Setups ohne Surrogate-Keys bleibt nur eine konservativ kurze TTL, was den Caching-Vorteil von Automatic Persisted Queries für volatile Daten deutlich reduziert.
8. Sicherheit: Persisted-Query-Only-Mode und Denylisting
Automatic Persisted Queries bringen einen Sicherheitsvorteil, der oft übersehen wird: Im Persisted-Query-Only-Mode akzeptiert der Server ausschließlich Hashes, die bereits im Store hinterlegt sind, und lehnt beliebigen, freien Query-Text kategorisch ab. Das schließt eine ganze Klasse von Angriffen aus, bei denen böswillige Clients absichtlich teure, tief verschachtelte Queries konstruieren, um den Server zu überlasten, da nur vorab bekannte, im Build-Prozess registrierte Queries überhaupt ausgeführt werden können.
Für öffentliche APIs, bei denen beliebige Drittclients zugreifen sollen, ist dieser Modus ungeeignet, weil er voraussetzt, dass alle erlaubten Queries vorab bekannt sind. Für interne APIs, etwa das eigene Storefront-Frontend, ist er dagegen die empfohlene Konfiguration. Ergänzend lässt sich ein Denylisting für einzelne, missbrauchte Hashes einrichten, etwa wenn ein kompromittierter Client Requests mit ungewöhnlich hoher Frequenz sendet, ohne dafür gleich die gesamte Persisted-Query-Funktion abzuschalten.
9. Automatic Persisted Queries im Vergleich
Ob sich Automatic Persisted Queries lohnen, hängt stark vom Traffic-Profil ab. Für kleine interne Tools mit wenigen Nutzern ist der Zusatzaufwand kaum spürbar. Für öffentliche, stark frequentierte Storefronts ist die Kombination aus Automatic Persisted Queries und CDN-Caching dagegen oft der Unterschied zwischen einem Origin-Server, der unter Last einbricht, und einem, der die meisten Requests nie zu Gesicht bekommt.
| Ansatz | HTTP-Methode | CDN-cachefähig | Bewertung |
|---|---|---|---|
| Standard-GraphQL (POST) | POST | Nein | Jeder Origin-Hit, keine Edge-Entlastung |
| APQ ohne GET-Umschaltung | POST | Nein | Kleinere Payload, aber weiterhin unkachebar |
| Automatic Persisted Queries + GET | GET | Ja | Stabiler Cache-Key, CDN-fähig, Mutations bleiben POST |
| Persisted-Query-Only + CDN | GET | Ja | Zusätzlich Schutz gegen beliebige, teure Queries |
Die Tabelle zeigt: Der entscheidende Schritt ist nicht das Hashing allein, sondern die Umschaltung auf GET. Ohne GET bleibt Automatic Persisted Queries eine reine Payload-Optimierung ohne CDN-Nutzen. Erst die Kombination aus Hash-Handshake, GET-Requests und passender Cache-Control-Konfiguration am Origin macht GraphQL-Traffic tatsächlich edge-cachefähig, mit allen Performance- und Sicherheitsvorteilen, die daraus entstehen.
Mironsoft
GraphQL-Architektur, Caching und Performance für Magento & Headless-Frontends
GraphQL-API unter Last am Origin überlastet?
Wir richten Automatic Persisted Queries ein, konfigurieren CDN-Caching mit sauberen Cache-Keys und Invalidierung, und sorgen dafür, dass eure GraphQL-Storefront auch unter Spitzenlast schnell bleibt.
APQ-Setup
Apollo Client und Server mit persistedQueryLink und Redis-Cache verbinden
CDN-Konfiguration
Cache-Keys, Vary-Header und Surrogate-Key-Invalidierung für Fastly, Cloudflare, Varnish
Security-Review
Persisted-Query-Only-Mode gegen teure, missbrauchte Queries absichern
10. Zusammenfassung
Automatic Persisted Queries lösen ein strukturelles Problem von GraphQL: POST-Requests mit variablem Query-Text sind für HTTP-Caching ungeeignet. Durch den zweistufigen Hash-Handshake bekommt jede Query eine stabile Identität, die sich als GET-Request übertragen lässt. Erst diese Umschaltung auf GET öffnet die Tür für CDN-Caching, mit Cache-Keys aus Hash und Variablen, korrekten Vary-Headern für Store- und Locale-Kontext, und Surrogate-Key-basierter Invalidierung für volatile Daten.
Wer Automatic Persisted Queries produktiv einsetzt, sollte drei Dinge sauber trennen: den Persisted-Query-Store für Hash-zu-Query-Zuordnungen, den Response-Cache am CDN für die eigentlichen Antworten, und den Persisted-Query-Only-Mode als Sicherheitsschicht gegen beliebige, teure Queries. Zusammen ergeben diese Bausteine eine GraphQL-API, die unter Last nicht am Origin, sondern am Edge beantwortet wird.
Automatic Persisted Queries mit CDN-Caching — Das Wichtigste auf einen Blick
Hash-Handshake
SHA256-Hash statt Query-Text, zweistufiger Handshake bei PersistedQueryNotFound, danach genügt der Hash allein.
GET statt POST
Nur GET-Requests sind CDN-cachefähig. Apollo Client steuert das über useGETForHashedQueries, Mutations bleiben POST.
Cache-Keys & Invalidierung
Hash plus Variablen plus Store-Kontext im Cache-Key. Surrogate-Keys ermöglichen gezieltes Purging statt vollständiger Cache-Leerung.
Sicherheit
Persisted-Query-Only-Mode blockiert beliebigen Query-Text und schützt gegen teure, missbrauchte Anfragen.