APIer/10 · Servers and performance
CHAPTER 10 · Resolvers, N+1, DataLoader

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.

§01

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.

schema.graphql · the blog contract from chapter 08
1type User {
2 id: ID!
3 name: String!
4 email: String!
5 posts: [Post!]!
6}
7
8type Post {
9 id: ID!
10 title: String!
11 body: String!
12 author: User!
13 comments: [Comment!]!
14}
15
16type Comment {
17 id: ID!
18 body: String!
19 author: User!
20}
21
22type Query {
23 post(id: ID!): Post
24 posts: [Post!]!
25}
resolvers.js · Apollo Server style
1const resolvers = {
2 Query: {
3 post: (parent, args, context) => context.db.findPost(args.id),
4 posts: (parent, args, context) => context.db.allPosts(),
5 },
6 Post: {
7 // parent is the post object the level above just returned
8 author: (post, args, context) => context.db.findUser(post.authorId),
9 comments: (post, args, context) => context.db.commentsOf(post.id),
10 },
11 // Post.title, Post.body and User.name are missing on purpose.
12 // A field with no resolver uses the default resolver:
13 // it reads the property of the same name from parent.
14};
15
16const server = new ApolloServer({ typeDefs, resolvers });
The default resolver saves most of the typing. The post object that came back from the database already has a 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.
One query tree, executed step by step
Query.post
Post.titledefault
Post.author
User.namedefault
Waiting to run…
The query arrives. The server does not look up a route. It takes this tree of fields and walks it, starting at the Query root type.
1 / 6
§02

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.js · build it, then use it in a resolver
1// Declared once when the server starts: build a context per request
2const { url } = await startStandaloneServer(server, {
3 context: async ({ req }) => {
4 const user = await getUserFromToken(req.headers.authorization);
5 return { user, db, loaders: makeLoaders() };
6 },
7});
8
9// It is the third argument of every resolver — here it decides access:
10const resolvers = {
11 Mutation: {
12 deletePost: async (parent, { id }, context) => {
13 const post = await context.db.findPost(id);
14 if (!context.user || context.user.id !== post.authorId) {
15 throw new GraphQLError("You are not the author of this post", {
16 extensions: { code: "FORBIDDEN" },
17 });
18 }
19 return context.db.deletePost(id);
20 },
21 },
22};
This is where chapter 06 connects. The token arrives in an HTTP header. The context function checks it once and turns it into 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.
§03

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.

Query counter · the N+1 problem in action
{
posts { # 3 posts
title
author { # nested!
name
}
}
}
0SQL queries
(no queries yet)
The client sends an ordinary query: a list of posts, each with its author. Watch the query counter on the right.
1 / 6

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.

§04

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.

loaders.js · a very small change
1import DataLoader from "dataloader";
2
3// A batch function takes many ids and returns one
4// result per id, in the same order and the same length
5const makeLoaders = () => ({
6 user: new DataLoader(async (ids) => {
7 // One query for all of them
8 const rows = await db.users.whereIdIn(ids);
9 const byId = new Map(rows.map((u) => [u.id, u]));
10 return ids.map((id) => byId.get(id) ?? null);
11 }),
12});
13
14// In the resolver: record the id instead of querying
15const resolvers = {
16 Post: {
17 author: (post, args, context) => context.loaders.user.load(post.authorId),
18 },
19};
Notice where 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:

The same query, this time with DataLoader
{
posts { # 3 posts
title
author { # nested!
name
}
}
}
0SQL queries
(no queries yet)
The same query, the same 3 posts. One difference: inside the author resolver, db.findUser(id) became loader.load(id).
1 / 5
§05

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.

Way 1

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:

The nested response from the server
{
  "post": {
    "__typename": "Post",
    "id": "1",
    "title": "Meeting GraphQL",
    "author": {
      "__typename": "User",
      "id": "9",
      "name": "Ada"
    }
  }
}
The local store in the client
Post:1{ title: "Meeting GraphQL", author: → User:9 }
User: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.

Way 2

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.

The hash stands for the query
1GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"3f2a91c8…"}}&variables={"id":"1"} HTTP/1.1
2Host: api.example.com
The JSON in the query string is percent-encoded in a real request; it is shown unencoded here so it stays readable. If the server accepts only hashes that were registered in advance, the same mechanism is a query allowlist: a query the server has never seen is rejected before it is executed. §06 comes back to this.
Way 3

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.

§06

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.

About a hundred bytes
1{
2 posts {
3 comments {
4 author { # the author of a comment
5 posts { # that author's posts
6 comments { # the comments on those posts…
7 author {
8 posts { title }
9 }
10 }
11 }
12 }
13 }
14 }
15}
Nothing here is invalid. It only uses the cycle that already exists in the schema: Post → Comment → User → Post. Each extra level multiplies the number of resolver calls, so a short query can ask the server to do an enormous amount of work. REST does not have this problem, because each endpoint returns a shape the backend fixed in advance.
Defense 1
Depth limit

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.

Defense 2
Complexity or cost scoring

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.

Defense 3
Timeout

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.

§07

Practice

Three tasks from the server side: count resolver calls, write a batch function, then act as the gatekeeper.

§08

Quiz

Eight questions. For the N+1 counting one, work the number out on paper instead of guessing.

QUESTION 01 / 8

What are the four arguments of a resolver, in order?

QUESTION 02 / 8

While executing { post(id:"1") { author { name } } }, what does the Post.author resolver receive as parent?

QUESTION 03 / 8

The schema declares title: String!, but the resolvers object has no Post.title. What happens at execution time?

QUESTION 04 / 8

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?

QUESTION 05 / 8

Which two mechanisms does DataLoader use against N+1? (select two)

QUESTION 06 / 8

In chapter 05, REST responses could be cached with ETag and Cache-Control. Why does GraphQL usually miss out on that?

QUESTION 07 / 8

What do persisted queries actually give you?

QUESTION 08 / 8

What does a query depth limit protect against?

What to take away from this chapter
  • Every field is produced by a resolver with the signature (parent, args, context, info). Execution is a walk down the field tree from the Query root, and each parent field's return value becomes the child's parent. A field with no resolver uses the default resolver, which reads the property of the same name from parent.
  • context is 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 __typename and id), 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.