GraphQL servers and performance
In the last three chapters you were the client writing queries. This chapter is about the server that answers them: which functions run, in what order, and why a small query can turn into a hundred database queries.
Resolvers: one function behind every field
A GraphQL server is an execution engine plus a table that maps each field to a function.
In REST, one URL is handled by one function. GraphQL works at a much smaller unit: every field in the schema is produced by a function, called a resolver. A resolver always receives the same four arguments: (parent, args, context, info) — the value the parent field returned, the arguments this field received, one object shared by the whole request (§02), and metadata about the current execution (rarely used). Running a query is a walk down the field tree that starts at the Query root type. A parent field runs first, and its return value is passed to its child fields as parent.
title property, so reading it is all that is needed. You only write a resolver yourself when the field name does not match the data, or when the field has to be computed or fetched — author has to exchange authorId for a user.Query root type.context: one object per request
Dozens of resolvers run independently, so they need one place to find the things they all use.
context is an object built when a request arrives. Every resolver in that one request receives the same object: the logged-in user, the database connection, and the DataLoader instances from §04. The words "per request" matter. The object does not survive into the next request, so a user stored there cannot leak into someone else's request, and a cache stored there cannot serve stale data later.
context.user — that is authentication. Each resolver then asks whether that user is allowed to do this particular thing — that is authorization, and in GraphQL it has to be decided field by field.The N+1 problem
The most common performance problem in a GraphQL server. It is much easier to see once than to describe.
Look again at the resolvers in §01. Post.author queries the database once, every time it runs. That is fine for one post. But what happens when the query is posts { author }? The list has N posts, so the author resolver runs N times: 1 query for the list, plus N queries for the authors. Each resolver only sees its own parent. None of them knows that the others are doing the same work. Watch the counter on the right.
The dangerous part is that no server code has to change
In REST the server decides the shape of every response, so a query like this one is written by the backend team and usually shows up in testing. GraphQL moves the shape of the query to the client. The day a frontend developer adds author { name } to an existing query, the number of database queries in production goes up, and no server code changed. That flexibility is real, and so is the cost. Handling it is the work of this chapter.
DataLoader
A small open-source library with two mechanisms: batching, and a cache that lives for exactly one request.
DataLoader replaces "each resolver queries on its own" with "collect the ids first, then run one query". When a resolver calls load(id), nothing is queried yet. The id is recorded and a Promise is returned. When the current tick of the event loop finishes, DataLoader takes the recorded ids, removes duplicates, fetches them all with one call to batchLoad([9, 12]), and gives each caller its own row. Inside one request, load(9), load(12), load(9) produces a single query — the repeated 9 is answered from the cache and is not even part of the batch.
makeLoaders() was called in §02: inside the context function. Create the loaders once per request. Their cache has no expiry, so a single shared instance would keep returning the same user object after that user changed their name.The same query again, with the loader in place. Watch where the counter stops:
author resolver, db.findUser(id) became loader.load(id).Caching
Chapter 05 showed how a REST response can be cached by the browser, a CDN, or a proxy. GraphQL loses most of that. Here are three ways to get it back.
Recall how REST caching works: a GET request and one URL per resource, so the URL itself is the cache key. A browser, a CDN, or a proxy can look at the URL and decide whether it may reuse a stored response, and an ETag lets the server answer 304 Not Modified with no body at all. The traditional GraphQL setup is different: every query is a POST to the same /graphql URL, and the query text sits in the body. The cache key is gone, the caches in between cannot tell two different queries apart, and HTTP caching stops working. This is a real architectural cost — but there are three practical answers.
A normalized cache in the client
If the layers in between cannot cache, move the cache into the client. Libraries such as Apollo Client give every object an identity built from __typename and id, then flatten the nested response into a small local store:
{
"post": {
"__typename": "Post",
"id": "1",
"title": "Meeting GraphQL",
"author": {
"__typename": "User",
"id": "9",
"name": "Ada"
}
}
}The nesting is gone. Each object is stored once, and the objects point at each other by reference. A later query that needs User:9 is answered locally, and a mutation that changes Ada's name updates every view that reads her — one copy, one value.
Persisted queries: send a hash instead of the text
Chapter 09 showed that once the changing values are moved into variables, the query text stays the same on every request. So there is no reason to send those few kilobytes again and again. Register the query with the server in advance, and send only its SHA-256 hash. The request becomes much smaller, and the hash plus the variables are short enough to fit in a URL — which means the request can use GET, and a CDN can cache it again. Only queries may be sent this way. A mutation changes data, so it keeps using POST, exactly as chapter 01 described for safe and unsafe methods.
GraphQL over HTTP: a specification still in draft
The GraphQL Foundation is writing a specification called GraphQL over HTTP. It is a working draft, not a ratified standard, although several servers already follow it. It defines how to send a query with GET, and adds the media type application/graphql-response+json. Under that media type a request error — a query the server could not parse or validate — may be answered with a 4xx status instead of 200. A query that executes but has a field failure still returns 200 with an errors array, exactly as chapter 07 described. So the two old rules "GraphQL is always POST" and "GraphQL always answers 200" are becoming less absolute. You will still meet both in existing projects.
Security and abuse
Letting the client choose the shape of the query also lets an attacker choose it. Start by looking at what an expensive query looks like.
Reject a query whose nesting goes deeper than a fixed number of levels, for example 7, before execution starts. It is a blunt rule and it takes minutes to add (GraphQL Armor and similar libraries provide it), but it stops the simplest attacks.
A shallow query over huge lists is expensive too. Give each field a price: a scalar costs 1, a list costs its first argument multiplied by the cost of its selection set. Add the score up before executing, and reject anything over budget. The GitHub GraphQL API rate limit works this way. You will set the prices yourself in the practice section.
Whatever the first two rules fail to catch is caught by the oldest rule of all: stop a single query after N seconds. One user seeing an error is much better than one query holding the whole server.
Should introspection be disabled in production? Both sides have a point
Disable it: introspection hands the whole schema to anyone who asks, which is a map for an attacker, and an introspection query can itself be expensive to answer. Apollo Server disables it in production by default, and OWASP lists it as a hardening step. Keep it on: hiding the schema is security by obscurity. The queries can be read out of the frontend bundle or guessed, and public APIs such as GitHub's leave introspection enabled on purpose. Both sides agree on what actually protects the server: a persisted-query allowlist and field-level authorization. The introspection switch is a small extra measure either way.
The other side: these costs are real
In 2024 Matt Bessey published Why, after 6 years, I'm over GraphQL, written after six years of using it. He reports an unauthenticated introspection query of about 128 bytes that used roughly ten seconds of CPU on a public API. His four complaints are the four subjects of this chapter: the attack surface, the cost of field-level authorization (REST checks once at the endpoint, GraphQL has to check every field), N+1, and observability — hundreds of different queries arrive at one endpoint, so monitoring and debugging need different tools.
These costs are real. Large teams absorb them with federation, cost analysis, and a full set of tools, and get the flexibility in return. A small team that cannot maintain that tooling may be better served by REST. Neither one replaces the other; each is a trade-off — the finale works through that decision.
Practice
Three tasks from the server side: count resolver calls, write a batch function, then act as the gatekeeper.
Quiz
Eight questions. For the N+1 counting one, work the number out on paper instead of guessing.
What are the four arguments of a resolver, in order?
While executing { post(id:"1") { author { name } } }, what does the Post.author resolver receive as parent?
The schema declares title: String!, but the resolvers object has no Post.title. What happens at execution time?
The query is { posts { title author { name } comments { body } } }, and posts returns 3 posts. The resolvers for author and comments each query the database on their own. How many database queries in the worst case?
Which two mechanisms does DataLoader use against N+1? (select two)
In chapter 05, REST responses could be cached with ETag and Cache-Control. Why does GraphQL usually miss out on that?
What do persisted queries actually give you?
What does a query depth limit protect against?
- Every field is produced by a resolver with the signature
(parent, args, context, info). Execution is a walk down the field tree from theQueryroot, and each parent field's return value becomes the child'sparent. A field with no resolver uses the default resolver, which reads the property of the same name fromparent. contextis built once per request and shared by every resolver in it: the current user, the data sources, the loaders. "Per request" is what keeps one user's data out of another user's response.- N+1:
posts { author }costs 1 query for the list plus N queries for the authors. A client-side change to the query can trigger it with no change on the server. - DataLoader has two mechanisms: it batches the
load()calls made in the same tick of the event loop into one query, and it answers a repeated id from a per-request cache. The counter drops from 1+N to 2. - One endpoint, POST, and a body that changes every time is what breaks HTTP caching. The three answers: a normalized cache in the client (keyed by
__typenameandid), persisted queries (send a hash, use GET, reach the CDN), and the draft GraphQL over HTTP specification. - Flexibility cuts both ways: use a depth limit, cost scoring, and a timeout together. Whether to disable introspection is debated; a persisted-query allowlist plus field-level authorization is the defense that matters.