GraphQL resolvers are the workhorses of your GraphQL API, fetching the data for each field in your schema. Writing them cleanly and performantly is crucial for a smooth user experience and a scalable backend.

Imagine a client requesting a list of users, and for each user, their profile picture and the number of posts they’ve written. Your GraphQL server receives this. The top-level users resolver kicks in, likely hitting a database for the user list. Then, for each user returned, the profilePicture and postCount resolvers are invoked. This is where the common performance pitfalls lie.

Here’s a concrete example of a common, but inefficient, approach in Node.js with a hypothetical db object for database operations:

// gql/resolvers.js

const resolvers = {
  Query: {
    users: async (_, __, { db }) => {
      const users = await db.getUsers(); // Fetches all users
      return users.map(user => ({
        ...user,
        // The problem: profilePicture and postCount are resolved individually for each user
        profilePicture: db.getProfilePicture(user.id), // N+1 problem here
        postCount: db.countPosts(user.id) // N+1 problem here
      }));
    },
  },
  User: {
    profilePicture: async (user, _, { db }) => {
      // This resolver might be called *after* users are fetched,
      // but if not batched, it still leads to N+1
      return db.getProfilePicture(user.id);
    },
    postCount: async (user, _, { db }) => {
      // Same N+1 issue
      return db.countPosts(user.id);
    }
  }
};

export default resolvers;

The users resolver fetches all users. Then, for each user, the profilePicture and postCount fields are resolved. If you have 100 users, db.getProfilePicture is called 100 times, and db.countPosts is called 100 times. This is the classic "N+1 query problem." Your database is hammered with a thousand tiny requests instead of a few efficient ones.

The Fix: DataLoader for Batching and Caching

The most effective solution for N+1 problems in GraphQL is using a library like dataloader. It’s designed specifically to batch and cache requests within a single GraphQL request lifecycle.

First, install it: npm install dataloader

Then, integrate it into your context and resolvers. Your context will hold instances of DataLoader.

// gql/context.js

import DataLoader from 'dataloader';
import { getProfilePicture, countPosts, getUsers } from '../db'; // Assume these are your DB functions

const batchProfilePictures = async (userIds) => {
  const pictures = await getProfilePicturesBatch(userIds); // A new function that fetches multiple pictures at once
  const pictureMap = new Map(pictures.map(pic => [pic.userId, pic]));
  return userIds.map(id => pictureMap.get(id) || null);
};

const batchPostCounts = async (userIds) => {
  const counts = await countPostsBatch(userIds); // A new function that counts posts for multiple users
  const countMap = new Map(counts.map(c => [c.userId, c]));
  return userIds.map(id => countMap.get(id)?.count || 0);
};

export const createContext = ({ db }) => {
  return {
    db,
    loaders: {
      user: new DataLoader(userIds => getUsersBatch(userIds)), // Example for fetching users in batches
      profilePicture: new DataLoader(batchProfilePictures),
      postCount: new DataLoader(batchPostCounts),
    },
  };
};

Now, modify your resolvers to use these DataLoaders:

// gql/resolvers.js

// Assume user object has an 'id' field
const resolvers = {
  Query: {
    users: async (_, __, { loaders }) => {
      // The users resolver itself can also use a DataLoader if needed for batching user fetching
      const users = await loaders.user.loadMany([]); // Placeholder, assuming users are fetched another way or this is for a specific user query
      return users; // The actual fetching logic might be elsewhere or this resolver is simpler
    },
  },
  User: {
    // The 'user' argument here is the parent object, typically the user object itself
    profilePicture: async (user, _, { loaders }) => {
      return loaders.profilePicture.load(user.id);
    },
    postCount: async (user, _, { loaders }) => {
      return loaders.postCount.load(user.id);
    }
  }
};

export default resolvers;

When loaders.profilePicture.load(user.id) is called for the first time for a given user.id within the same GraphQL request, DataLoader queues up that user.id. If loaders.profilePicture.load(anotherUser.id) is called shortly after, it also gets queued. When the event loop has a moment, DataLoader executes its batch function (batchProfilePictures in our example) with all the unique userIds that were requested. This single call to getProfilePicturesBatch fetches all necessary pictures. The results are then distributed back to the individual load calls. This transforms hundreds of individual database calls into one or a few batched calls.

Another common pitfall: Over-fetching and Under-fetching

GraphQL’s strength is its ability to request exactly what you need, but this cuts both ways. Developers often write resolvers that fetch too much data from the database (over-fetching) or require multiple round trips to resolve a single field (under-fetching, which DataLoader helps with).

Clean Resolver Structure

Keep resolvers focused. A resolver should ideally:

  1. Fetch data: Use DataLoaders or direct DB calls for the specific data needed for its field.
  2. Transform data: Perform any necessary formatting or aggregation.
  3. Return data: In the shape expected by the GraphQL schema.

Avoid complex business logic directly in resolvers. Delegate that to service layers or domain models.

The "One Thing" Most People Don’t Know

The DataLoader library automatically deduplicates identical keys (userId in our example) within a single batch execution. If your profilePicture resolver is called 50 times with the same userId within a single request, DataLoader will only include that userId once in the batch request to your underlying data store. This is a powerful, built-in optimization that many users aren’t fully aware of, often writing their own deduplication logic unnecessarily.

The next error you’ll likely encounter is related to handling errors within your batched data fetching operations, or dealing with deeply nested N+1 problems that might require multiple levels of DataLoaders.

Want structured learning?

Take the full Graphql-tools course →