Crux Read real GraphQL resolver and DataLoader code, predict the N+1 or correctness behaviour, and pick the highest-leverage fix.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at senior altitude — in orbit
◷ 14 min
N+1 problems are diagnosed in resolver code, batch functions, and the validation layer — not in prose. Read each snippet, predict what it does under 50 posts or a hostile client, then choose the fix a senior engineer makes first.
Goal
Practise the loop you run on every GraphQL review: spot the per-field fetch, audit the batch contract, and confirm the query-shape gate — before the database log lights up in production.
Snippet 1 — the resolver that fires N+1
const resolvers = { Query: { posts: () => db.query('SELECT * FROM posts LIMIT 50'), }, Post: { // called once per post, in isolation author: (post) => db.query( 'SELECT * FROM users WHERE id = $1', [post.authorId] ), },};
Quiz
Completed
For posts { title author { name } } over 50 posts, how many DB queries fire, and what is the structural fix?
Heads-up The engine does not batch; each Post.author call runs independently. You get 1 + 50 = 51 queries until you add DataLoader.
Heads-up Parallelism does not remove the 50 queries; it only spreads the load. The fix is to collapse them into one batch with DataLoader, not to run them faster.
Heads-up Query.posts returns posts, not users; Post.author must fetch each author separately. That separate fetch per post is exactly the N+1.
Snippet 2 — the batch function
async function batchAuthors(ids) { const rows = await db.query( 'SELECT id, name FROM users WHERE id = ANY($1)', [ids] ); // returns rows in whatever order Postgres chose return rows;}
Quiz
Completed
This batch function returns the raw rows. What is the latent bug, and how do you fix it?
Heads-up It does not. Row order from IN/ANY is unspecified, so values can land on the wrong keys. Reorder with a Map keyed by id.
Heads-up Throwing rejects every pending .load() in the batch. For a missing row, return null (or an Error in that slot) — but the primary defect here is ordering.
Heads-up IN and ANY behave the same for ordering — neither guarantees it. Ordering is fixed in the batch function with a Map, not in the SQL syntax.
Snippet 3 — the one-to-many loader
async function batchTags(postIds) { const rows = await db.query( 'SELECT post_id, tag FROM tags WHERE post_id = ANY($1)', [postIds] ); const map = new Map(); rows.forEach(r => { if (!map.has(r.post_id)) map.set(r.post_id, []); map.get(r.post_id).push(r.tag); }); return postIds.map(id => map.get(id)); // posts with no tags?}
Quiz
Completed
A post with zero tags has no rows, so map.get(id) is undefined. What goes wrong, and what is the fix?
Heads-up They are not. undefined breaks a non-null list field at validation, while [] is a valid empty list. Default missing keys to [].
Heads-up Because the function maps over postIds (not the raw rows), positions do not shift — but undefined slots still break the field. Return [] for tagless posts.
Heads-up DataLoader never retries; it resolves with whatever value sits in that slot. undefined must be replaced with an empty array by your code.
Snippet 4 — the validation gate
const server = new ApolloServer({ schema, validationRules: [depthLimit(10)], // only depth is capped});
Quiz
Completed
This server caps depth at 10 but nothing else. A client sends a 5-level query with first: 100 at every level. What gets through, and what is the missing layer?
Heads-up A 5-level query is under the depth cap yet expands to 100^5 rows. Depth limiting cannot see list-argument multiplication; complexity scoring can.
Heads-up DataLoader collapses DB trips per level, but 100^5 rows are still read and materialised. Query-shape defences must reject the document before resolvers run.
Heads-up Introspection only hides the schema for reconnaissance; it does not stop an expensive query shape. Complexity scoring is the defence.
Recap
Every GraphQL N+1 incident is read in code: a per-field resolver that fetches one row per parent (51 queries → DataLoader), a batch function that trusts database row order (corruption → Map by key), a one-to-many loader that drops empty arrays (validation error → default to []), and a validation layer that caps depth but not row expansion (100^5 leak → multiplicative complexity plus alias caps). Read the resolver, audit the batch contract, confirm the gate — then re-trace resolver counts to verify the fix held.