Resolvers
A schema declares what data exists; resolvers are the functions that actually fetch it. GraphQL executes a query by walking its tree top-down, calling one resolver per requested field and feeding each parent's result into its children. Resolvers are where your API meets databases, REST services, and other backends — and where the infamous N+1 query problem lives, the single most important performance pitfall in GraphQL. This page covers the resolver signature and its four arguments, the execution model, default resolvers, the N+1 problem, and how DataLoader batches it away.
The resolver signature — four arguments
const resolvers = {
Query: {
// Every resolver receives the same four arguments:
user: (parent, args, context, info) => {
// parent — the result of the PARENT field (undefined for root fields)
// args — the field's arguments: { id: '42' }
// context — per-request shared object (auth, db, dataloaders)
// info — AST details about the query (rarely needed)
return context.db.users.findById(args.id)
},
},
User: {
// 'parent' here is the User returned above; resolve its posts:
posts: (parent, args, context) => context.db.posts.findByAuthor(parent.id),
},
}Argument | Is | Most common use |
|---|---|---|
| The value the parent field resolved to | Read |
| The arguments passed to this field | Lookup keys, filters, pagination |
| Per-request object shared by all resolvers | Auth ( |
| AST / execution metadata | Advanced: look-ahead, field selection |
The execution model — walking the tree
Query: { user(id: "42") { name posts { title } } }
Execution walks the tree, calling a resolver per field:
Query.user(id:"42") → returns user #42
└─ User.name (parent=user) → "Ada" (default resolver: parent.name)
└─ User.posts (parent=user) → [post, post] (your resolver: find by author)
└─ Post.title (parent=post) → "..." (default: parent.title)
• Children run only AFTER the parent resolves (parent feeds child).
• Sibling fields (name, posts) resolve in PARALLEL.
• A field's resolver runs ONLY if the client asked for that field.Default resolvers — what you don't write
The N+1 problem — the classic GraphQL trap
// Query: { posts { title author { name } } }
const resolvers = {
Query: {
posts: () => db.posts.findAll(), // 1 query → 100 posts
},
Post: {
// This runs ONCE PER POST — 100 posts ⇒ 100 separate author queries:
author: (post) => db.users.findById(post.authorId), // ❌ N+1
},
}-- The database sees: SELECT * FROM posts; -- 1 query SELECT * FROM users WHERE id = 1; -- + N queries SELECT * FROM users WHERE id = 2; -- one per post... SELECT * FROM users WHERE id = 3; -- ...100 round-trips ... -- = 1 + N = catastrophic
DataLoader — batch and cache per request
import DataLoader from 'dataloader'
// The batch function receives ALL ids collected during one tick, returns
// results in the SAME ORDER as the keys:
function createLoaders(db) {
return {
userById: new DataLoader(async (ids) => {
const users = await db.users.findByIds(ids) // ONE query: WHERE id IN (...)
const map = new Map(users.map((u) => [String(u.id), u]))
return ids.map((id) => map.get(String(id)) ?? null) // align to key order
}),
}
}
// Create loaders PER REQUEST in the context function (never share across requests):
const context = async ({ req }) => ({ user: await getUser(req), loaders: createLoaders(db) })
const resolvers = {
Post: {
// .load() collects ids this tick; DataLoader fires ONE batched query:
author: (post, _args, { loaders }) => loaders.userById.load(post.authorId), // ✅
},
}-- Now the database sees just: SELECT * FROM posts; SELECT * FROM users WHERE id IN (1, 2, 3, ...); -- ONE batched query → 2 total
Resolver best practices
Keep resolvers thin — delegate to a service/model layer (
context.db,userService); a resolver should orchestrate, not contain business logic.Authorize inside resolvers using
context.user— check permissions on the fields and mutations that need them; there is no per-route middleware to lean on.Batch every list→related-entity edge with DataLoader — assume N+1 exists until you have proven it batched.
Return Promises freely — GraphQL awaits them, and sibling fields resolve in parallel, so independent async work overlaps for free.
Throw
GraphQLErrorwith anextensions.codefor expected failures (not-found, unauthorized) so clients can branch on the error type.Use
infosparingly — look-ahead (detecting which sub-fields were requested) is powerful for optimizing queries but adds complexity; reach for it only when measured.