Backend Systems · API Styles

GraphQL Execution: Resolvers, Fan-Out, and Query Cost

How a GraphQL document becomes a tree of resolver calls, why list fields produce N+1 queries, and which limits keep a single endpoint from being a denial-of-service vector.

GraphQL replaces a fixed set of endpoints with one endpoint and a schema, and moves the selection of fields from the server to the client. The client sends a document describing exactly the data it wants, the server validates it against the schema, and an execution engine walks the document, calling a resolver for each field. The result is a JSON object with the shape of the query. That property, that response shape mirrors request shape, is the whole selling point and also the source of the style’s characteristic failure modes: work proportional to the query rather than to the endpoint, and a request whose cost is not visible in its URL.

The execution model is specified in enough detail that implementations agree on ordering, error shape, and null propagation. The performance model is not specified at all. Everything about cost control is an application decision, and the defaults are dangerous for a public endpoint.

A document executes as a tree of fields

A GraphQL operation has a selection set. The executor resolves each field in that set, producing a value, and then recurses into the sub-selection of object-typed fields using that value as the parent. Scalar and enum fields are leaves. The response is assembled in the order the fields appear in the document, which matters for clients that read raw text but rarely otherwise.

Execution order has two rules. Within a selection set, fields are resolved independently and may be resolved in parallel, because the specification imposes no order and no dependency between siblings. The top-level fields of a mutation are the exception: they execute serially, in document order, so that one mutation can depend on the effect of the previous one. Nested fields of a mutation still execute in parallel within each level.

Each field has a resolver function receiving four arguments: the parent object, the field arguments, the execution context, and an info object describing the field and schema. A field with no resolver uses a default resolver that reads a property of the same name from the parent. That default is why a schema can be layered over plain objects, and why a field backed by another service needs an explicit resolver.

Fragments and aliases complicate the tree without changing the shape of the result. A fragment spreads a selection set into several places, and the executor merges identical field selections that share a response key. An alias renames a field in the response, which lets one query request the same field with different arguments. Aliases are the mechanism behind a common amplification trick: a query that asks for the same expensive field ten thousand times under different aliases is a valid document with ten thousand resolvers to run.

The N+1 pattern is inherent to field-level resolution

Consider a query for the first fifty orders and the customer name on each. The executor calls the orders resolver once, gets fifty order objects, then calls the customer resolver once per order. If that resolver performs a database lookup by customer identifier, the request issues fifty-one queries: one for the list, fifty for the customer names. The pattern generalises to any related object reached through a list, and to nested lists it becomes multiplicative.

The naive fixes each have a failure mode. Joining the customer table into the orders query works for that exact selection, and breaks as soon as the client asks for a different relation, or does not ask for the customer at all. Caching customers by identifier helps when the same customer appears in many orders and does nothing when fifty distinct customers appear. The request-scoped batch loader is the general answer: resolvers declare a key they need, the loader collects keys during the current execution turn, and issues one query for the whole set.

function makeBatchLoader(batchFn) {
  let queue = [];
  let cache = new Map();

  function dispatch() {
    const batch = queue;
    queue = [];
    batchFn(batch.map((entry) => entry.key)).then((values) => {
      batch.forEach((entry, i) => {
        cache.set(entry.key, values[i]);
        entry.resolve(values[i]);
      });
    });
  }

  return function load(key) {
    if (cache.has(key)) return Promise.resolve(cache.get(key));
    return new Promise((resolve) => {
      queue.push({ key, resolve });
      // Defer to the end of the current microtask turn so sibling
      // resolvers scheduled by the same parent all join this batch.
      if (queue.length === 1) queueMicrotask(dispatch);
    });
  };
}

The scheduling detail is the part that is easy to get wrong. Batching works only when keys accumulate before the batch function runs. A loader that dispatches immediately on the first load call degenerates to one query per key. Deferring by a microtask collects every resolver that the current turn schedules, which is exactly the set of siblings produced by the parent’s list. Deferring by a timer would collect more, including work from unrelated parents, and would add latency to every field.

The per-request cache is not an optimisation detail but a correctness and cost requirement. Two resolvers asking for customer 17 during one request must share a result, and a loader scoped to the request naturally provides that. A loader scoped to the process provides a cache with no invalidation, which produces stale data across requests.

Cost has to be bounded before execution runs

A GraphQL endpoint accepts arbitrary documents, including deeply nested fragments and aliased repetitions, so the server must decide what to execute before doing the work. Three families of limit are common, and they compose.

Depth limiting counts nesting levels. A limit of ten or fifteen rejects pathological recursion through self-referential types, which is the usual way a document asks for an exponential amount of work. Depth limits are cheap to compute from the validated document and easy to explain, and they do not bound breadth: a query of depth three with a thousand aliases at each level is still enormous.

Complexity scoring assigns a cost to each field and sums over the document, accounting for list multipliers supplied as arguments. A field returning a list with a first argument costs its base cost multiplied by the requested count, capped at the maximum page size the resolver enforces. The sum has to be computed on the validated document, with fragments expanded and aliases counted separately. Weighting is empirical: it approximates the work per field and can be tuned as resolvers change.

Persisted queries invert the problem. The client sends a hash of a document that was registered at build time, and the server executes only documents it already knows. This removes arbitrary-document cost from the public surface entirely and lets a CDN cache responses by hash, since the request becomes a GET with the hash in the query string or a header. It also breaks ad hoc exploration, which is why many APIs allow persisted queries on public endpoints and free-form documents only for development clients.

Introspection is part of the same surface. The schema discloses types, fields, and arguments, which is useful for tooling and also maps the attack surface. Disabling introspection on a public endpoint is a common measure whose value is limited, since the schema shape leaks through error messages and the client bundle, but the cost of leaving it on is a larger document that a complexity analyser must still process.

Errors arrive as data, and nulls propagate

A GraphQL response has a data member and an errors member. A field that fails produces an entry in errors with a message, a path array locating the field, and optional locations and extensions. The data member contains whatever was resolved successfully, so a single request can succeed partially. That is a sharp contrast with HTTP, where one status code describes the whole exchange.

Null propagation is the part clients find surprising. If a field is declared non-null and its resolver returns null, or throws, the null propagates upward to the nearest nullable parent, which is then set to null. A non-null field failure inside a list element therefore nulls the element, and a non-null list of non-null elements nulls the entire list. The specification defines this precisely, and it means that adding a non-null marker to an existing field can turn a partial success into a nulled parent when the backing data has gaps. Fields that may legitimately be absent should be nullable.

HTTP status codes still exist around the GraphQL layer. A request that fails validation returns 400 before execution. A request that executes and has field errors typically returns 200 with errors in the body, which is why client libraries must inspect the body rather than the status line. Mapping GraphQL errors to transport-level retry decisions is the subject of error models across API styles, and the mismatch is the main reason GraphQL sits awkwardly behind caches built for HTTP semantics.

The single endpoint defeats HTTP caching

A POST to /graphql is not cacheable by shared caches, because the request body is part of the query and no standard cache keys on body content. Every response goes to the origin, and the entire fan-out happens per request. This is the structural cost of the style: it removes the request-level caching that makes a resource API cheap under load.

Two mitigations exist. Persisted queries with GET restore cacheability, since the URL then identifies the document and the variables can be normalised into the query string, at the price of a URL length limit and a cache key per variable combination. Client-side normalised caches reduce repeat requests from a single client but do nothing for the first request or for clients without a cache. Neither restores the property that a representation can be cached once and served to many clients, because the representation depends on a client-chosen selection.

Batching at the transport layer, sending an array of operations in one HTTP request, is a third approach. It reduces connection overhead and lets a server share loader caches across operations, but it also multiplies the cost of one request and needs its own limits. The specification does not define batched execution, so the shape is whatever the server implements, and streaming transports are a better fit when the intent is to incrementally deliver a long-running result.

References

  1. GraphQL Specification — GraphQL Foundation
  2. DataLoader — GraphQL Foundation
  3. GraphQL Foundation: Learn — GraphQL Foundation