GraphQL's per-field flexibility comes with a genuine trap baked into its execution model — a query that looks perfectly innocent can quietly issue hundreds of database queries if nobody's paying attention to how resolvers actually run.
3 min read
Every field in a GraphQL schema (lesson 4) has a resolver — a function
responsible for fetching that specific field's data. When a query asks for
a list of users and each user's posts, the execution engine calls the
users resolver once, then calls the posts resolver once per user
returned:
query {
users {
name
posts { title }
}
}Fetching 50 users takes 1 query. Naively fetching each of their posts, one resolver call at a time, takes N additional queries — one per user — for a total of N+1 database round trips to answer a single GraphQL request. This scales directly with result size: a query returning 500 users instead of 50 makes the exact same code issue 501 queries instead of 51, and nothing in the resolver code itself looks obviously wrong — each individual resolver is simple and correct in isolation.
This isn't a rare edge case — it's the default behavior of the most naive, obvious way to write GraphQL resolvers, meaning a team that doesn't know to watch for it will write N+1 queries by default, not by mistake in some unusual situation. And because each individual resolver call is fast, the problem often isn't visible in local development with a handful of test rows — it only becomes obviously slow in production, at real data volume, which makes it a particularly easy trap to ship unnoticed.
The standard solution, popularized by Facebook's DataLoader library (and equivalents in other GraphQL server ecosystems), defers each individual resolver's request just long enough to collect a batch of them within the same execution tick, then issues one query for the whole batch instead of one query per item:
Instead of the resolver directly querying the database, it calls
postsLoader.load(userId), which returns a promise but doesn't execute
immediately — the loader waits, collects every .load() call issued during
that tick, then fires a single batched query (WHERE user_id IN (...))
and distributes each row back to its corresponding promise. The resolver
code barely changes; the execution pattern underneath changes completely.
If the same key is requested twice within one tick (two different parts of a query both needing user 5's data, say), a DataLoader caches the promise for that key within the current request and returns the same one both times — automatically avoiding a duplicate query for identical data requested more than once in the same execution, on top of the batching benefit.
A REST endpoint like GET /users?include=posts typically has one
hand-written implementation that a developer explicitly writes a single
join or batched query for — the "N+1 by default" trap exists specifically
because GraphQL's resolver-per-field execution model composes arbitrary
nested queries generically, without a human writing custom fetching logic
for each possible query shape. This is a genuine cost of GraphQL's
flexibility (lesson 4), not a flaw exclusive to any one implementation —
every real GraphQL server needs a batching strategy like this one, not as
an optional optimization but as a near-mandatory part of using GraphQL
correctly at any real scale.
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why does a naive GraphQL resolver implementation produce N+1 queries by default?
2. How does a DataLoader solve the N+1 problem?
3. Why might the N+1 problem not be noticed during local development?
API Design