← Back to the library
BackendApplied · 8 min

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 · library

The 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

POST every operation to /graphqlGET with persisted-query hashes plus Cache-Control from field cache hints
refetchQueries after every mutationMutations return the changed entities; evict, modify or refetch only for list membership changes

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

SOURCES

Guide reviewed