Skip to content
Codeloom
GraphQL

GraphQL Resolvers: A Complete Guide

Master GraphQL resolvers: resolver functions, context, the N+1 problem, DataLoader for batching, error handling, authorization in resolvers, and performance patterns.

·8 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • How resolver functions map to your schema
  • Using context for authentication and shared services
  • The N+1 problem and why it kills performance
  • DataLoader for batching and caching database queries
  • Error handling and authorization patterns in resolvers

Prerequisites

  • Basic GraphQL schema knowledge (types, queries, mutations)
  • Familiarity with async/await in JavaScript or Python
  • Basic database query experience

Resolvers are the functions that actually fetch data for your GraphQL schema. Every field in your schema has a resolver, whether you write one explicitly or rely on the default. Understanding how resolvers work, how they compose, and how to avoid their performance traps is essential for building a GraphQL API that performs well.

How resolvers work

A resolver is a function that returns the data for a single field. It receives four arguments:

// JavaScript/Node.js resolver signature
function resolver(parent, args, context, info) {
  // parent: the result from the parent resolver
  // args: arguments passed to this field
  // context: shared context (auth, database, etc.)
  // info: field-level metadata (rarely used directly)
}
# Python (Strawberry/Ariadne) resolver signature
def resolver(parent, info, **kwargs):
    # parent: (called 'root' or 'obj' in some frameworks)
    # info: contains context and field metadata
    # kwargs: field arguments
    pass

Default resolvers

If you do not write a resolver for a field, GraphQL uses a default resolver that returns parent[fieldName]. This is why simple object fields “just work.”

// Schema
// type User {
//   id: ID!
//   name: String!
//   email: String!
// }

const resolvers = {
  Query: {
    user: (_, { id }, { db }) => db.users.findById(id),
    // Returns { id: "1", name: "Alice", email: "alice@example.com" }
  },
  // No User resolvers needed!
  // User.id resolves to parent.id automatically
  // User.name resolves to parent.name automatically
  // User.email resolves to parent.email automatically
};

You only write a resolver when the field’s data is not a simple property access on the parent.

Resolver chains

Resolvers execute in a tree. The root query resolver runs first, then child resolvers run with the parent’s result.

const resolvers = {
  Query: {
    post: async (_, { id }, { db }) => {
      return db.posts.findById(id);
      // Returns { id, title, content, authorId, tagIds }
    },
  },

  Post: {
    // This resolver runs with the post object as parent
    author: async (post, _, { db }) => {
      return db.users.findById(post.authorId);
    },

    // Tags also resolve from the parent post
    tags: async (post, _, { db }) => {
      return db.tags.findByIds(post.tagIds);
    },

    // Computed field: no database query needed
    excerpt: (post) => {
      return post.content.substring(0, 200) + "...";
    },
  },
};

When a client queries:

query {
  post(id: "1") {
    title
    author {
      name
    }
    tags {
      name
    }
    excerpt
  }
}

The execution order is:

  1. Query.post resolves the post
  2. Post.author, Post.tags, and Post.excerpt run in parallel with the post as parent
  3. Default resolvers handle title, name on User, name on Tag

Context

Context is created once per request and shared across all resolvers. It is the right place for authentication, database connections, and service instances.

// Server setup
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: async ({ req }) => {
    const token = req.headers.authorization?.replace("Bearer ", "");
    const user = token ? await verifyToken(token) : null;

    return {
      user,
      db: database,
      loaders: createLoaders(), // DataLoaders (covered below)
    };
  },
});
// Using context in resolvers
const resolvers = {
  Query: {
    me: (_, __, { user }) => {
      if (!user) throw new AuthenticationError("Not authenticated");
      return user;
    },

    myPosts: async (_, __, { user, db }) => {
      if (!user) throw new AuthenticationError("Not authenticated");
      return db.posts.findMany({ where: { authorId: user.id } });
    },
  },
};

The N+1 problem

The N+1 problem is the biggest performance trap in GraphQL. It happens when a list query triggers individual database queries for each item’s relationships.

// This looks innocent...
const resolvers = {
  Query: {
    posts: (_, __, { db }) => db.posts.findMany({ take: 50 }),
    // 1 query: SELECT * FROM posts LIMIT 50
  },
  Post: {
    author: (post, _, { db }) => db.users.findById(post.authorId),
    // 50 queries: SELECT * FROM users WHERE id = ? (one per post)
  },
};

A query for 50 posts with their authors generates 51 database queries: 1 for the posts and 50 for the authors. With nested relationships (posts -> authors -> company), it multiplies further.

DataLoader: the solution

DataLoader batches and caches individual lookups within a single request. Instead of 50 separate findById calls, DataLoader collects all the IDs and makes one findByIds call.

import DataLoader from "dataloader";

function createLoaders() {
  return {
    userLoader: new DataLoader(async (userIds) => {
      // One query instead of N
      const users = await db.users.findMany({
        where: { id: { in: userIds } },
      });

      // DataLoader requires results in the same order as the keys
      const userMap = new Map(users.map((u) => [u.id, u]));
      return userIds.map((id) => userMap.get(id) || null);
    }),

    tagLoader: new DataLoader(async (tagIds) => {
      const tags = await db.tags.findMany({
        where: { id: { in: tagIds } },
      });
      const tagMap = new Map(tags.map((t) => [t.id, t]));
      return tagIds.map((id) => tagMap.get(id) || null);
    }),
  };
}

Use the loaders in resolvers:

const resolvers = {
  Query: {
    posts: (_, __, { db }) => db.posts.findMany({ take: 50 }),
  },
  Post: {
    author: (post, _, { loaders }) => {
      return loaders.userLoader.load(post.authorId);
      // DataLoader batches: all 50 authorIds are fetched in ONE query
    },
    tags: async (post, _, { loaders }) => {
      return Promise.all(
        post.tagIds.map((id) => loaders.tagLoader.load(id))
      );
    },
  },
};

Now the same query generates 3 database queries instead of 51:

  1. SELECT * FROM posts LIMIT 50
  2. SELECT * FROM users WHERE id IN (id1, id2, ..., id50) (batched by DataLoader)
  3. SELECT * FROM tags WHERE id IN (...) (batched by DataLoader)

DataLoader caching

DataLoader caches results within a single request. If two posts have the same author, the author is fetched once.

// Post 1 has authorId: "alice"
// Post 2 has authorId: "alice"
// Post 3 has authorId: "bob"

loaders.userLoader.load("alice"); // queued
loaders.userLoader.load("alice"); // cache hit, same promise
loaders.userLoader.load("bob");   // queued

// Actual query: SELECT * FROM users WHERE id IN ('alice', 'bob')
// Only 2 users fetched, not 3

Python DataLoader

from aiodataloader import DataLoader

class UserLoader(DataLoader):
    async def batch_load_fn(self, user_ids):
        users = await db.users.find_many(
            where={"id": {"in": list(user_ids)}}
        )
        user_map = {u.id: u for u in users}
        return [user_map.get(uid) for uid in user_ids]

# In resolver
async def resolve_author(post, info):
    return await info.context["user_loader"].load(post.author_id)

Error handling

Field-level errors

Throw errors in resolvers. GraphQL returns them alongside partial data.

const resolvers = {
  Query: {
    post: async (_, { id }, { db }) => {
      const post = await db.posts.findById(id);
      if (!post) {
        throw new UserInputError("Post not found", {
          argumentName: "id",
        });
      }
      return post;
    },
  },

  Post: {
    viewCount: async (post, _, { user }) => {
      // Only the author can see view counts
      if (!user || user.id !== post.authorId) {
        throw new ForbiddenError("Not authorized to view this field");
      }
      return post.viewCount;
    },
  },
};
// Schema:
// union CreatePostResult = Post | ValidationError | AuthError

const resolvers = {
  Mutation: {
    createPost: async (_, { input }, { user, db }) => {
      if (!user) {
        return {
          __typename: "AuthError",
          message: "Authentication required",
        };
      }

      if (!input.title.trim()) {
        return {
          __typename: "ValidationError",
          field: "title",
          message: "Title cannot be empty",
        };
      }

      const post = await db.posts.create({ data: { ...input, authorId: user.id } });
      return { __typename: "Post", ...post };
    },
  },
};

Authorization in resolvers

Field-level authorization

function requireAuth(resolverFn) {
  return (parent, args, context, info) => {
    if (!context.user) {
      throw new AuthenticationError("Authentication required");
    }
    return resolverFn(parent, args, context, info);
  };
}

function requireRole(...roles) {
  return (resolverFn) => {
    return (parent, args, context, info) => {
      if (!context.user) {
        throw new AuthenticationError("Authentication required");
      }
      if (!roles.includes(context.user.role)) {
        throw new ForbiddenError(`Requires role: ${roles.join(" or ")}`);
      }
      return resolverFn(parent, args, context, info);
    };
  };
}

const resolvers = {
  Query: {
    me: requireAuth((_, __, { user }) => user),
    adminStats: requireRole("ADMIN")((_, __, { db }) => {
      return db.stats.getAll();
    }),
  },
  Mutation: {
    deleteUser: requireRole("ADMIN")((_, { id }, { db }) => {
      return db.users.delete(id);
    }),
  },
};

Performance tips

Only resolve what is queried. Use the info argument to check which fields the client requested, and skip expensive computations for fields not in the selection set.

Post: {
  commentCount: async (post, _, { db }, info) => {
    // Only hit the database if this field was actually requested
    return db.comments.count({ where: { postId: post.id } });
  },
}

Create DataLoaders per request. DataLoader caches are scoped to a single request. Creating loaders in the context factory ensures no cross-request cache pollution.

Avoid resolver waterfalls. If a resolver depends on the result of a sibling resolver, restructure so the parent provides what both need.

Use database joins when appropriate. For deeply nested queries that always fetch the same shape, a single SQL join can be faster than multiple DataLoader batches.

Resolvers are where your schema meets your data. Keep them thin (delegate to services), batch with DataLoader, handle errors explicitly, and authorize at the field level. A well-structured resolver layer makes your GraphQL API both fast and maintainable.