Engineering Atlas › System Design Fundamentals

GraphQL: querying exactly what you need

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Schema, queries and resolvers

The schema defines object types, fields and their types, plus root Query, Mutation and Subscription types. A query selects fields from the root down. Execution walks the selection tree: for each field it calls that field's resolver with the parent object, and for list fields it does this per element. Fields on the same level can resolve in parallel; mutations at the root run serially. The spec separates two kinds of failure. A request error (the document does not parse, fails validation, or has bad variables) stops everything, and the response has errors but no data. An execution error in one field nulls that field and adds an entry to errors, while the rest of data returns, so a query can partially succeed.

Over HTTP, most servers have answered 200 for all of these with application/json. The GraphQL-over-HTTP working draft changes that for the newer application/graphql-response+json media type: 400 when the document cannot be parsed, 422 when it fails validation, and a custom 294 status for partial success, which clients that do not know it must treat as 200 (GraphQL over HTTP).

The N+1 problem and batching

Query posts(first: 50) { title author { name } }. The naive server runs one query for posts and 50 queries for authors. Nested one more level (comments and their authors), it becomes thousands. The standard fix is a DataLoader: within one frame of execution, resolvers call load(id) instead of querying; the loader collects all ids, issues one batched query (WHERE id IN (...)), and caches results for the rest of the request. Fifty author lookups become one. That cache is per request by design; the reference implementation says to create a new loader per request so one user's results never serve another with different permissions (DataLoader).

Cost control

A client can write a query that is deeply nested or fans out (users(first: 100) { friends(first: 100) { friends(first: 100) ... } }), which is a million objects from a tiny request. Defences, in increasing strength:

  1. Depth limit (reject nesting beyond 8 to 10), plus breadth limits on aliases and on operations per batch, since both multiply work without adding depth (graphql.org).
  2. Static cost analysis: multiply list sizes along each path and reject queries above a budget.
  3. Execution timeouts and per-client rate limits measured in cost points, not requests.
  4. Trusted documents: clients register their operations at build time and send only an ID or hash, so production only runs operations your own team reviewed. graphql.org calls the older name "persisted queries" a misnomer, and notes this cannot work for a public API whose callers write their own queries (graphql.org).

Public GraphQL APIs publish these limits as numbers. GitHub requires first or last between 1 and 100 on every connection, caps a call at 500,000 nodes, scores a query by the requests needed to fill its connections divided by 100, gives users 5,000 points an hour, and kills anything that runs over 10 seconds (GitHub docs). Shopify assigns every field a cost, rejects any single query above 1,000 points, reserves the requested cost before running and refunds the difference after, and refills each app's bucket at 100 to 2,000 points a second depending on the store's plan (Shopify docs).

Caching

REST gets CDN caching for free because each URL is a resource. GraphQL usually sends POST /graphql, so intermediaries cannot cache it. Options: send persisted queries as GET with the hash in the URL for public data (Apollo Client does this when useGETForHashedQueries is on; mutations stay POST) (Apollo docs), cache at the resolver or data-source layer, and use client libraries that normalise results by type and id so one update refreshes every view.

Federation

Large organisations split the schema across teams. A gateway composes subgraphs into one supergraph and plans each query across them, fetching an entity from one service and extending it with fields from another. This keeps one API for clients but adds a planning layer, cross-service latency, and schema governance work.

Down the stack

Layer 1The contract

The GraphQL spec promises a response whose data mirrors the query's shape and key order, plus an errors list with a path for each failure, such as ["hero", "heroFriends", 1, "name"], so a client can tell a real null from a failed field. A field that errors becomes null; if the field is declared non-null, the error propagates to the parent position, up to the nearest nullable one, which can blank out a whole subtree (GraphQL spec, September 2025). That edition, the first since October 2021, also added OneOf input objects (@oneOf, exactly one field set), schema coordinates for naming schema elements, and deprecation on more kinds of element (graphql.org). Top-level mutation fields execute serially in the order written; query fields may execute in any order or in parallel. Nothing in the contract bounds cost or latency; those are server policies.

Layer 2The mechanism

A server parses the query text into a document AST, validates it against the schema with a fixed set of rules (fields exist on their types, variables are used and typed correctly, fragments do not cycle), then executes. Execution runs CollectFields on a selection set, merging fragments and applying @skip and @include, then for each field calls the resolver, then completes the value: checks non-null, iterates lists, resolves abstract types to concrete ones, and recurses into sub-selections. A DataLoader exploits the event loop: every resolver at one level calls load(id) synchronously, the loader schedules a single flush for after the current microtask queue drains, and the flush issues one batched query. Automatic persisted queries send a SHA-256 hash in extensions.persistedQuery; on a cache miss the server replies with an error whose code is PERSISTED_QUERY_NOT_FOUND and the client resends the full text once (Apollo docs). Because any client can register any query this way, automatic persisted queries save bandwidth but are not an allowlist; trusted documents are.

Layer 3The machine

Parsing and validating a 2 KB query costs roughly 0.1 to 1 ms of CPU, so servers cache parsed documents by hash. Each resolved field typically allocates a promise and a result object; a list of 10,000 items with 20 fields each is 200,000 resolver calls and can block a single-threaded Node event loop for tens of milliseconds, stalling every other request on that process. On the data side, one WHERE id IN (...) query with 50 ids is a single network round trip of about 0.5 to 1 ms in-region and one index scan, against 50 round trips for the unbatched version. Large responses are dominated by JSON serialisation and compression, so limiting list sizes protects CPU as much as the database.

Why it's built this way

  1. Many clients on slow mobile networks need different data for each screen, so the client describes the shape and gets it in one round trip.
  2. The server cannot predict which shapes will arrive, so it composes small per-field resolvers instead of hand-written endpoints.
  3. Per-field resolution naturally fans out into N+1 queries, so batching hooks into the event loop to collect keys first.
  4. Open-ended queries have open-ended cost, so production servers analyse cost statically or accept only persisted queries.
  5. A single POST endpoint defeats URL-based HTTP caches, so caching moves into normalised client stores and the data layer.

Diagram

sequenceDiagram
    participant C as Client
    participant G as "GraphQL server"
    participant L as "Author DataLoader"
    participant P as "Posts DB"
    participant U as "Users DB"
    C->>G: posts(first:50) { title author { name } }
    G->>P: SELECT 50 posts
    P-->>G: 50 rows
    G->>L: load(author_id) x50
    L->>U: SELECT users WHERE id IN (12 unique ids)
    U-->>L: 12 rows
    L-->>G: resolve 50 author fields
    G-->>C: data { posts [...] }

Worked example

A mobile home screen needs the user profile, 20 feed items with author and like counts, and 5 notifications. Over REST this is 1 + 1 + 20 + 1 calls; on a 4G connection with 80 ms round trips and HTTP/2 multiplexing, the dependent calls still cost about 3 sequential round trips (240 ms) plus about 40 KB of over-fetched JSON. With GraphQL it is one round trip and about 9 KB.

Server side, without batching, the feed query triggers 1 feed query + 20 author queries + 20 like-count queries = 41 database round trips at 1 ms each, roughly 41 ms of serialised waiting. With DataLoaders for authors and like counts, it is 3 queries and about 4 ms.

Cost limiting: the team assigns cost 1 per object and multiplies by first. The home screen query costs about 20 x 3 + 6 = 66 points. They set a per-query cap of 1,000 and a per-client budget of 50,000 points per minute. A scraper sending users(first:100){followers(first:100){name}} costs 10,100 points and is rejected before execution.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
GraphQLClient-shaped responses, one round trip, typed schemaCost control, caching, N+1 by defaultMany clients with different data needs
RESTHTTP caching, simple ops, mature toolingOver- and under-fetching, many round tripsPublic resource APIs, CDN-heavy reads
gRPCFast, streaming, strong contractsNot browser-native, binaryInternal service-to-service calls
Federated GraphQLOne graph across teamsGateway planning, governance overheadDozens of teams behind one client API

Pitfalls in production

Leadership lens

Check yourself

  1. Why does GraphQL tend to produce N+1 queries?

    Show answer

    Each field resolver runs independently per parent object, so a list of N parents calls the child resolver N times unless calls are batched.

  2. Why is a depth limit alone not enough?

    Show answer

    A shallow query can still fan out hugely through large list arguments; cost must multiply list sizes along the path.

  3. How do persisted queries help caching?

    Show answer

    A known query hash can be sent as a GET URL, which CDNs and browsers can cache like any other resource.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →