119 Optimizing Query Resolvers with `dataloader`
119 Optimizing Query Resolvers with dataloader
When building an API with GraphQL, one of the biggest challenges is avoiding the N+1 query problem that often hides behind the resolver layer. Failing to optimize your resolvers forces the database to work harder than it should, degrades application performance, and can even drive up your cloud database bill. Fortunately, we have one trusty tool we can rely on: dataloader
. In this 119th article, I’ll dissect how dataloader works, how to implement it, and how it can deliver significant optimization to your query resolvers. We’ll also run a real-world case simulation, both before and after using DataLoader.
Understanding the Problem: N+1 Query
Imagine an API with a GraphQL schema like the following:
1type Post {
2 id: ID!
3 title: String!
4 author: User!
5}
6
7type User {
8 id: ID!
9 name: String!
10}
11
12type Query {
13 posts: [Post!]!
14}When a client requests all posts along with the author data of each post:
1query {
2 posts {
3 id
4 title
5 author {
6 id
7 name
8 }
9 }
10}The naïve resolver that’s typically written looks like this (pseudo-JS):
1const resolvers = {
2 Query: {
3 posts: () => PostModel.findAll(),
4 },
5 Post: {
6 author: (post) => UserModel.findById(post.authorId),
7 }
8}Sounds ordinary. But if there are 10 posts, the following happens:
- 1 query for the posts
- 10 queries for the users (fetching the author of each post)
A total of 11 queries! And if there are 1,000 posts? That’s 1,001 queries. This is the N+1 query problem.
N+1 Query Flow Diagram (Without DataLoader)
Let’s visualize how this process plays out:
sequenceDiagram
participant Client
participant API
participant DB
Client->>API: Query: posts { author { ... } }
API->>DB: SELECT * FROM posts
API->>DB: SELECT * FROM users WHERE id = X (for post 1)
API->>DB: SELECT * FROM users WHERE id = Y (for post 2)
API->>DB: ...
As you can see, for each post a separate query to the user table is executed.
The Solution: DataLoader
dataloader
is a library built by the Facebook team that implements batching and caching for data fetching, making it a perfect fit for GraphQL resolvers.
Its philosophy is simple: instead of running many separate queries, batch requests that share the same key within a single request and run a single query. On top of that, the batch results are also cached per request, so the same key won’t trigger a repeated query.
How DataLoader Works
Here’s an illustration of the flow:
flowchart LR
A[GraphQL Resolver] -->|Request data by ID| B[DataLoader]
B -->|Batch collect ID| C{Already cached?}
C -- No --> D[Query database with "WHERE id IN (...)" ]
D --> E[Map results to each ID]
C -- Yes --> F[Return from cache]
E --> G[Send to Resolver]
F --> G
Implementing DataLoader in a Resolver
The simple steps are:
- Create a DataLoader instance per request (so the cache is scoped per user request, not global),
- Create a batch function (usually a function that accepts an array of IDs and returns a promise of an array of data in the same order as the input IDs),
- Use the DataLoader in the resolvers that need it.
Installation
1npm install dataloaderBatch Function
1const DataLoader = require('dataloader');
2const UserModel = require('./user-model');
3
4// batchFn accepts an array of userIds and returns Promise<array user>
5const batchGetUsers = async (userIds) => {
6 /* Query all users at once whose id is in userIds */
7 const users = await UserModel.findAll({
8 where: { id: userIds }
9 });
10
11 // Make sure the order matches the input userIds
12 return userIds.map(id => users.find(u => u.id === id));
13};Creating a DataLoader per Request
In a GraphQL server context (for example, Apollo Server):
1const createLoaders = () => ({
2 userLoader: new DataLoader(batchGetUsers),
3});
4
5// in the Apollo context setup:
6const server = new ApolloServer({
7 typeDefs, resolvers,
8 context: () => ({
9 loaders: createLoaders()
10 }),
11});Using the DataLoader in a Resolver
1const resolvers = {
2 Query: {
3 posts: () => PostModel.findAll(),
4 },
5 Post: {
6 author: (post, _, { loaders }) => loaders.userLoader.load(post.authorId),
7 }
8};With this, even if a client requests 1,000 posts and each one needs its author, there will be only two queries:
- One query for the posts (SELECT * FROM posts)
- One query for the users (SELECT * FROM users WHERE id IN (…))
Simulation: Comparison Without vs. With DataLoader
| Scenario | Number of Posts | Requests to DB (without DataLoader) | With DataLoader |
|---|---|---|---|
| Simple | 5 | 1 + 5 = 6 | 2 |
| Medium | 100 | 1 + 100 = 101 | 2 |
| Extreme | 1000 | 1 + 1000 = 1001 | 2 |
That’s up to a 99% reduction in queries!
Case Study: Simulated Output
Let’s test two GET requests, one without and one with DataLoader.
Without DataLoader (Pseudo-Log)
1Query: posts { author { name } }
2[QUERY] SELECT * FROM posts;
3[QUERY] SELECT * FROM users WHERE id = 101;
4[QUERY] SELECT * FROM users WHERE id = 102;
5[QUERY] SELECT * FROM users WHERE id = 103;
6[QUERY] ... N times (number of posts)With DataLoader (Pseudo-Log)
1Query: posts { author { name } }
2[QUERY] SELECT * FROM posts;
3[QUERY] SELECT * FROM users WHERE id IN (101, 102, 103, ... N);Only two queries, no matter the value of N! The database load drops drastically.
Advantages & Implementation Notes for DataLoader
Advantages
- Efficient: Far fewer database queries.
- Per-Request Cache: Safe from cache pollution across users/requests, and thread-safe.
- Declarative: Just swap it in at the resolver; the rest of the API stays agnostic.
Notes
- Batch Query Needs Mapping: You need to map the output data back to the input order to keep the results correct.
- Cache per Request, Not Global: To keep data safe across users.
Best Practices
- Create the DataLoader in the Context: Don’t make it a global singleton.
- Batch Intensive Queries: Focus first on resolvers that frequently hit N+1, such as parent-child relationships.
- Monitor Query Patterns: Use logging at the ORM/database layer to detect query explosions.
Conclusion
The N+1 query problem isn’t just a tall tale when building a GraphQL API. It can hide behind the convenience of the GraphQL resolver pattern. But by adopting dataloader, we can optimize our query resolvers very easily, even without having to overhaul our data model. With just a few changes to the resolver and context, API performance goes up, the database load gets lighter, and the code becomes more maintainable.
Don’t just recognize the problem, put the optimization into practice!
Happy coding, and may your GraphQL app run faster than ever 🚀
References: