GraphQL

Caching, N+1, And Performance

GraphQL trades REST's many cacheable URLs for one flexible endpoint — which means performance work shifts location. The two problems that define GraphQL performance are the N+1 resolver pattern on the server and the loss of URL-based HTTP caching on the wire. This lesson covers both, plus the supporting cast.

N+1: The Signature GraphQL Performance Bug

Consider this innocent query:

{
  artist(slug: "nova-tide") {
    albums(first: 20) {
      title
      label {
        name
      }
    }
  }
}

Naive resolvers execute: one query for the artist, one for 20 albums — then the Album.label resolver runs once per album, each firing its own SELECT * FROM labels WHERE id = ?. That is 1 + 1 + 20 database queries where 3 would do. Nest one level deeper and it multiplies. This is N+1, and it is not a rare bug — it is the default behavior of per-field resolvers touching storage. REST endpoints hand-tune one query per route; GraphQL's composability means you cannot hand-tune every possible query shape.

The Fix: Batching Loaders

The standard remedy (popularized by the DataLoader pattern) changes resolver behavior from "fetch now" to "promise to fetch, then fetch together":

  1. during a resolution pass, each label resolver hands its labelId to a loader and returns a deferred value instead of hitting the database;
  2. when the executor has collected the whole pass, the loader fires one batched query — WHERE id IN (l_1, l_2, ...) — and distributes results to the waiting fields;
  3. the loader also memoizes per request, so the same label asked for twice loads once.

In graphql-php the primitive is Deferred; the resolver becomes:

PHP example
'label' => function (array $album, array $args, AppContext $ctx): Deferred {
    $ctx->labelLoader->queue($album['label_id']);
    return new Deferred(
        fn () => $ctx->labelLoader->result($album['label_id'])
    );
},

where LabelLoader accumulates ids and runs the IN (...) query once on first result() call. Libraries exist, but the pattern is small enough to own. Two rules make it work well: loaders live in the request context (per-request lifetime — a shared loader would leak data across users), and every resolver that fetches by id goes through a loader, because you cannot predict which combinations queries will exercise.

Batch-loading turns query cost from "number of objects" into roughly "depth of query" — the shape the cost-analysis limits from the security lesson assume.

HTTP Caching: What You Lose And What Remains

REST's superpower is that GET /albums/301 is a stable URL: browsers, CDNs, and proxies cache it with zero application code. GraphQL POSTs to one URL, so that entire layer sees an uncacheable stream by default. Your options, roughly in order of effort:

  • GET for queries with the document in the URL restores standard Cache-Control/CDN behavior — practical mainly for a small set of public, personalization-free queries, within URL length limits.
  • Persisted queries (hash-identified operations, from the security lesson) make GET practical — ?id=abc123&variables=... is short and stable, and CDNs can cache the popular ones.
  • Server-side application caching is unaffected: resolvers and the business layer cache in Redis/APCu exactly as in any PHP app; a whole-response cache keyed on (query hash, variables, viewer role) can help hot public queries.
  • Accept that per-user data mostly should not be edge-cached anyway — the loss is smaller than it first appears.

Client-Side Caching

The flexible-query model moves serious caching into the client. GraphQL clients (Apollo, Relay, urql) maintain a normalized cache: every object in every response is stored once, keyed by its global id (this is where the previous lesson's id discipline pays off), and queries are answered by reassembling from that store. Consequences for the API designer: always make id selectable where caching matters, keep ids globally unique, and return the changed entity from mutations so the cache updates itself. A schema designed this way makes client caches work automatically; one with ad-hoc ids forces every frontend team into manual cache code.

The Rest Of The Performance Toolkit

  • Resolve only what was asked. Compute expensive fields in their own resolvers, never eagerly in the parent — the whole point of field granularity is that non-requested fields cost nothing. ResolveInfo can tell a root resolver which children were selected when it pays to prefetch joins.
  • Response size still matters. first: 100 on three nested connections is megabytes of JSON; enforce list caps and let clients ask for less. Enable compression at the web server.
  • Measure per field. Instrument resolver timings (many servers can emit per-field traces) so you learn which field makes the query slow — aggregate endpoint latency is nearly useless when one endpoint serves every query shape.
  • Cache the parsed schema. Building a large schema on every PHP-FPM request costs real milliseconds; graphql-php supports lazy type loading, and OPcache plus schema caching keeps startup flat as the schema grows.

What To Check

Before moving on, make sure you can:

  • spot N+1 in a resolver design before running it, and estimate the query count for a given GraphQL query
  • explain the three moves of a batching loader: queue, batch-fire, memoize
  • say why loaders must be request-scoped
  • describe what URL-based HTTP caching GraphQL loses and which techniques restore parts of it
  • explain what a normalized client cache needs from your schema
  • name the per-field measurement and schema-startup concerns specific to GraphQL servers

What You Should Be Able To Do

After this lesson, you should be able to take a slow GraphQL endpoint and fix it methodically: trace per-field timings, batch the N+1 hot spots with request-scoped loaders, decide which caching layer (edge, server, client) each query actually benefits from, and design new schema features so that ids and mutation payloads keep client caches correct for free.