Once N+1 is solved, the remaining costs of a GraphQL API are bytes on the wire, parsing and validation, and resolvers that recompute identical results for every caller. After this lesson you will be able to shrink requests with persisted queries, lock the API down to known operations, and cache responses at the server and CDN level.
| Stage | Cost | Remedy | |---|---|---| | Transport | Large query strings in every POST body | Persisted queries, GET requests | | Parse and validate | CPU per unique document | Document caching (built into Apollo Server and Yoga) | | Execution | Database and service calls | DataLoader, response caching | | Serialisation | Large responses | Select fewer fields, paginate, compress |
Modern servers already memoise parsing per document, so the biggest wins come from the first and third rows.
A query document for a complex screen can be several kilobytes and is identical on every request. Automatic persisted queries (APQ) replace it with a SHA-256 hash: if the server has not seen the hash it answers PERSISTED_QUERY_NOT_FOUND, the client resends hash plus full text once, and from then on only the hash travels:
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";
const link = createPersistedQueryLink({ sha256 }).concat(
new HttpLink({ uri: "https://api.example.com/graphql", useGETForQueries: true })
);Apollo Server supports APQ out of the box with an in-memory store; with several instances, share the store:
import Keyv from "keyv";
import { KeyvAdapter } from "@apollo/utils.keyvadapter";
const server = new ApolloServer({
schema,
persistedQueries: { cache: new KeyvAdapter(new Keyv(process.env.REDIS_URL)) },
});useGETForQueries matters because a GET with only a hash in the URL is cacheable by CDNs, which POST bodies never are.
APQ is an optimisation, not a security control: any client can register any document. Persisted operations (trusted documents) go further: at build time every frontend operation is extracted into a manifest of hash: document pairs, and at runtime the server executes only hashes it knows:
import { usePersistedOperations } from "@graphql-yoga/plugin-persisted-operations";
import manifest from "./persisted-operations.json" with { type: "json" };
const yoga = createYoga({
schema,
plugins: [usePersistedOperations({ getPersistedOperation: (hash) => manifest[hash] ?? null })],
});GraphQL Code Generator's client preset can emit this manifest, and Apollo GraphOS offers the same as "safelisting". With arbitrary queries impossible, depth and complexity limits matter far less.
Some fields change rarely and are identical for every user. Apollo Server's built-in cache control plugin reads @cacheControl hints and emits a Cache-Control header computed from the most restrictive field in the response:
enum CacheControlScope { PUBLIC PRIVATE }
directive @cacheControl(maxAge: Int, scope: CacheControlScope, inheritMaxAge: Boolean) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
type Query {
categories: [Category!]! @cacheControl(maxAge: 3600)
me: User @cacheControl(maxAge: 0, scope: PRIVATE)
}A GET for categories can then be served by a CDN for an hour. To cache whole responses inside the server, add ApolloServerPluginResponseCache from @apollo/server-plugin-response-cache, keyed by document, variables and a session id for PRIVATE scope. GraphQL Yoga's useResponseCache does the same and invalidates entries when a mutation returns a matching __typename and id.
maxAge: 0 as the default and opt fields in; a wrong hint serves one user's data to another.What happens the first time an APQ client sends a hash the server has never seen?
useGETForQueries, makes queries CDN-cacheable.@cacheControl hints produce Cache-Control headers; response cache plugins store full results server-side.Next lesson: Federation and Schema Stitching — combine several GraphQL services into one graph with Apollo Federation or graphql-tools stitching.