The CDN never caches GraphQL, and the client cache drifts
“Every page view hits the origin because the CDN shows a 0% hit rate on /graphql. In the app, editing a product updates the detail view but the list still shows the old name, or two items swap data.”
ERROR CODES YOU MAY SEE
A code is a clue. Use its meaning and surrounding evidence to narrow the cause.
PERSISTED_QUERY_NOT_FOUNDApollo Server automatic persisted queries · libraryThe request carried only a query hash and the server has not stored the matching query string yet; the client is expected to retry with the full query.
A short burst after deploys or cache evictions is normal. A steady rate means the APQ cache is too small, not shared across instances, or the client never sends the follow-up.
Official reference for PERSISTED_QUERY_NOT_FOUND (opens in new tab)Code definition reviewed
THE PRINCIPLE
A cache needs a stable key. Over HTTP that key is the URL (method + persisted-query hash + variables); in a normalized client cache it is __typename plus a unique id.
FIRST MOVES
- Check the method and headers on /graphql. POST, no Cache-Control or no-store, and a Vary that includes the cookie all explain a 0% hit rate.
- Send queries as GET with a persisted-query hash (APQ or trusted documents) so URLs stay short and stable. Never run mutations over GET; the GraphQL-over-HTTP spec forbids it.
- Set cache hints per field (@cacheControl maxAge, PRIVATE scope for anything user-specific). The response gets the lowest maxAge of its fields and no-store when any field is uncacheable or the result has errors.
- On the client, make sure each type has a unique id, or declare keyFields. Objects without one are stored inside their parent and never update in place.
- After a mutation, return the changed fields so the cache merges them, and evict or refetch lists that a create or delete changes.
TOOLS: THEN → NOW
PATTERN SNAPSHOT
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import { PersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";
const cache = new InMemoryCache({
typePolicies: { Product: { keyFields: ["sku"] } }, // Product has no id field
});
const link = new PersistedQueryLink({ sha256, useGETForHashedQueries: true })
.concat(new HttpLink({ uri: "/graphql" }));
export const client = new ApolloClient({ cache, link });
// After deleteProduct: drop the entity from every list that referenced it.
export function forgetProduct(sku: string) {
cache.evict({ id: cache.identify({ __typename: "Product", sku }) });
cache.gc();
}CLOSE THE AI. EXPLAIN THIS.
Why does a mutation that returns the updated object fix the detail view but not a list after a delete, and what does the cache need to fix the list?HOW IT WORKS UNDERNEATH
RELATED SYMPTOMS
SOURCES
Guide reviewed