Performance & N+1
|
This section documents the current GraphQL specification (October 2021), plus the working draft for
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as GraphQL tooling iterates quickly. |
GraphQL’s client-driven field selection is also where its most common performance trap lives: a naively written set of resolvers can turn one query into dozens or hundreds of round trips to a database or another service. This page covers the N+1 problem, the DataLoader pattern that fixes it, and the other resolver- and transport-level techniques that keep a GraphQL server fast as selections get deep and wide.
The N+1 problem
Consider a query that lists books and, for each one, asks for its author:
query {
books {
title
author {
name
}
}
}
If Query.books runs one query to fetch every book, and each Book.author resolver independently runs its
own query to fetch that one book’s author, the total cost is one query for the list plus N more queries — one per book — for the authors. Double the list depth (books, each with an author, each author with a list of
other books) and the same pattern compounds at every level, since
Execution & Resolvers walks every item in a list field’s
result and invokes that item’s own resolvers independently of its siblings. Nothing about this is a GraphQL
specification defect — the execute stage is defined field by field precisely so each resolver can be simple
and unaware of its siblings — but that same independence is what makes the naive version of a nested resolver
expensive.
Why nested resolvers create it
The root cause is that a per-item resolver, written the obvious way, only knows about the one parent item it was called with — it has no visibility into the fact that it is being called once per item in a list, and so no way to combine its own work with a sibling call’s work on its own:
const resolvers = {
Query: {
// one query: returns the full list of books
books: (parent, args, context) => context.db.listBooks(),
},
Book: {
// called once *per book* in that list -- N separate round trips to the database,
// one for each book's author, even though every call happens within the same request
author: (parent, args, context) => context.db.findAuthorById(parent.authorId),
},
};
For three books this is four total queries (one list query, three author queries); for a hundred books it is 101. The resolver code itself looks perfectly reasonable in isolation — the problem is only visible once you count how many times the runtime calls it during a single request.
The DataLoader pattern: batch and cache
DataLoader is a small, widely adopted utility (originally built at Facebook alongside GraphQL.js) that solves this by inserting a per-request layer between a resolver and its data source. Instead of a resolver fetching its own item immediately, it hands a key to a loader and receives a promise for that key’s value; the loader does two things no individual resolver call can do on its own:
-
Batches. Rather than dispatching a request the instant
.load(key)is called, the loader collects every key requested during the current tick of the event loop and, on the next tick, dispatches one batch function call carrying all of them — turning N individual author lookups into a singleWHERE id IN (…)style query. -
Caches. Within the scope of one loader instance, loading the same key twice returns the same in-flight (or already-resolved) promise instead of issuing a second fetch — so if two different fields in the same query both need author
7, only one lookup for7ever reaches the database.
Both behaviors are scoped to a single loader instance, and a loader instance is created fresh per request
(typically inside the server’s context factory) so that one client’s cached results are never handed to a
different client’s request. See the DataLoader reference implementation
for the full API and its batch-scheduling behavior, and
graphql.org/learn/performance for this pattern described from the
schema-design side.
Naive calls vs. the batch-and-cache fix
The diagram below contrasts the two call patterns for the three-book query above: the naive version issues one
database round trip per book as each Book.author resolver runs independently, while the DataLoader version
lets all three resolver calls enqueue their key first and dispatches a single batched round trip once every
resolver in that tick has had a chance to call .load():
Three individual database round trips on the left collapse into one on the right, and any later resolver in the same request that loads an already-fetched id is served from the loader’s cache instead of triggering a fourth query.
Implementing a DataLoader
Rewriting the naive Book.author resolver against a loader only changes where the fetch happens, not the
resolver’s own shape — it still returns one author for one book, but now goes through a batching layer:
const DataLoader = require("dataloader");
// created once per request, e.g. inside the server's `context` factory
function createLoaders(db) {
return {
authorLoader: new DataLoader(async (authorIds) => {
// called once with every id requested during this tick -- must return results
// in the same order as authorIds, with null/Error in place of any id not found
const rows = await db.findAuthorsByIds(authorIds);
const byId = new Map(rows.map((row) => [row.id, row]));
return authorIds.map((id) => byId.get(id) ?? null);
}),
};
}
const resolvers = {
Book: {
author: (parent, args, context) => context.loaders.authorLoader.load(parent.authorId),
},
};
The resolver’s own code barely changed — context.db.findAuthorById(…) became
context.loaders.authorLoader.load(…) — but the loader now guarantees the batching and per-request caching
shown in the diagram above without the resolver itself needing to know how many siblings it has.
Resolver-level lookahead and projections
DataLoader fixes the number of round trips; it does not shrink what each round trip fetches. A resolver that
always selects every column of a row, regardless of which fields the client actually asked for, still wastes
bandwidth and database work on unrequested data. The execution info argument
(Execution & Resolvers introduces its four resolver
parameters) carries the parsed selection set for the current field, so a resolver can inspect which subfields
were actually requested and look ahead to fetch only those columns or joins:
const { fieldsList } = require("graphql-fields-list");
const resolvers = {
Query: {
books: (parent, args, context, info) => {
// e.g. only ["title"] if the client's query selected nothing else on Book
const requestedFields = fieldsList(info);
return context.db.listBooks({ select: requestedFields });
},
},
};
A query that only ever selects title never needs to load a book’s larger description column or join its
author row at all — lookahead lets the resolver make that decision from the client’s own selection set
instead of over-fetching by default. See graphql.org/learn/performance
for lookahead described alongside the other techniques on this page.
Pushing filters and pagination down to the datastore
The same principle applies to arguments, not just selected fields: a resolver that fetches an entire table and
then filters, sorts, or slices it in application code pays for every row it discarded. Pushing a field’s
args — a status filter, a sortBy, a pagination cursor and page size — straight into the underlying
query’s own WHERE/ORDER BY/LIMIT clauses means the datastore itself does the discarding, and only the
rows the client will actually see ever cross the wire between the resolver and its data source:
const resolvers = {
Query: {
books: (parent, args, context) =>
// args.filter/args.first/args.after come straight from the client's query;
// the datastore filters and pages, the resolver never sees the discarded rows
context.db.listBooks({ filter: args.filter, first: args.first, after: args.after }),
},
};
Pagination covers cursor-based pagination itself — the first/after
argument shape and the PageInfo/edges response shape — in depth; this page only notes that pushing those
arguments down to the datastore, rather than paginating an already-fully-loaded array in memory, is what keeps
a paginated field’s cost proportional to the page size instead of the table size.
The cost of deep and wide selections
Because a client chooses its own selection set, a single query can request a deep selection (an object
nested many levels below the root) or a wide one (a list field with a large page size, or many aliased
copies of the same expensive field) without the server having agreed to that shape in advance. Even with
batching and datastore pushdown in place, a sufficiently deep or wide query still does proportionally more
resolver work and returns a proportionally larger response than a shallow one — batching reduces the number
of round trips a given shape costs, it does not make an arbitrarily large shape free. Bounding how deep or wide
a client is allowed to go is a demand control concern rather than a resolver-performance one:
Security & Demand Control covers depth limiting,
breadth/alias limiting, and query cost analysis for rejecting an overly expensive query before it executes at
all.
Response compression
Everything above reduces how much work the server does to produce a response; compressing that response
before it goes over the wire reduces how long it takes to deliver it, independently of how it was produced.
A GraphQL response is ordinary JSON served over HTTP, so the same Content-Encoding negotiation any HTTP API
uses applies unchanged — a client sends Accept-Encoding: gzip, br, and a server (or an intermediary proxy or
CDN in front of it) compresses the response body accordingly:
POST /graphql HTTP/1.1
Accept-Encoding: gzip, br
Content-Type: application/json
HTTP/1.1 200 OK
Content-Encoding: br
Content-Type: application/json
Large GraphQL responses — a wide list selection, or many aliased fields — compress especially well because
JSON’s repeated key names and structural punctuation are exactly the kind of redundancy general-purpose
compression removes; enabling it is usually a one-line server or reverse-proxy setting rather than anything
GraphQL-specific. See
MDN’s Content-Encoding reference
for the header semantics and the gzip/br (Brotli) encodings themselves.
Related pages
-
Execution & Resolvers — the resolver signature and the per-item execution behavior over list fields that creates the N+1 pattern in the first place.
-
Pagination — the cursor-based argument shape this page’s datastore pushdown example pushes down to the underlying query.
-
Security & Demand Control — bounding how deep or wide a client-chosen selection is allowed to be, rather than only making each shape cheaper to execute.
-
Caching — response and persisted-query caching, which reduces repeat work across requests rather than across resolvers within one request.