Solving N+1 Queries in CMS GraphQL Resolvers with DataLoader
This guide, part of GraphQL vs REST API Tradeoffs, fixes the most common performance problem in GraphQL layers built on top of a headless CMS: resolvers that make one CMS request for every item in a list. It shows how to recognize the pattern, batch lookups with DataLoader, keep loaders scoped to a single request, and measure the improvement.
The problem does not appear when you query the CMS’s own GraphQL API, which resolves references internally. It appears as soon as you build your own GraphQL layer: a BFF, a federation subgraph wrapping a CMS REST API, or a gateway that combines the CMS with commerce or search data. Each resolver is written to handle one object, which is the right design, and the GraphQL executor calls it once per object, which is where the requests multiply.
The Problem
A media company built a GraphQL BFF over its CMS’s REST API so that the web and app teams could share one schema. The homepage query requested 40 article cards, each with an author, a primary category and a hero image asset. The BFF’s resolvers fetched each reference by id, so the query made 1 request for the list and 120 for references. At normal traffic it was merely slow, around 900 milliseconds. During a breaking news spike it exhausted the CMS’s rate limit within seconds, and the homepage started returning partial data with errors.
How DataLoader Works
DataLoader is a small utility that collects individual load(key) calls made during one tick of the event loop and passes them to a single batch function as an array of keys. The batch function fetches all of them at once and returns the results in the same order. Resolvers keep their simple one-object shape, calling loaders.author.load(article.authorId), while the actual CMS traffic becomes one request per reference type per query level.
It also caches within its lifetime: if twenty articles share five authors, the batch function receives five unique ids. That cache is the reason loaders must be created per request. A loader shared across requests would serve one user’s data to another and never see updates, which is a correctness and sometimes a security problem, not just a staleness issue.
Implementation
Create loaders in the GraphQL context factory so each request gets fresh ones. The batch function must return results in the same order as the keys, with null or an Error for missing entries, because DataLoader matches results to callers by position.
// graphql/loaders.ts
import DataLoader from "dataloader";
interface Author { id: string; name: string; avatarUrl?: string }
interface Category { id: string; slug: string; title: string }
async function fetchByIds<T extends { id: string }>(path: string, ids: readonly string[]): Promise<(T | null)[]> {
const url = new URL(`${process.env.CMS_REST_URL}/${path}`);
url.searchParams.set("ids", ids.join(","));
url.searchParams.set("limit", String(ids.length));
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN}` } });
if (!res.ok) throw new Error(`CMS ${path} batch failed: ${res.status}`);
const items = (await res.json()).items as T[];
const byId = new Map(items.map((item) => [item.id, item]));
return ids.map((id) => byId.get(id) ?? null); // same order as keys, null for missing or unpublished
}
export function createLoaders() {
return {
author: new DataLoader<string, Author | null>((ids) => fetchByIds<Author>("authors", ids), { maxBatchSize: 100 }),
category: new DataLoader<string, Category | null>((ids) => fetchByIds<Category>("categories", ids), { maxBatchSize: 100 }),
};
}
export type Loaders = ReturnType<typeof createLoaders>;
// graphql/server.ts
import { createYoga, createSchema } from "graphql-yoga";
import { createLoaders, type Loaders } from "./loaders";
const typeDefs = /* GraphQL */ `
type Author { id: ID! name: String! avatarUrl: String }
type Category { id: ID! slug: String! title: String! }
type Article { id: ID! title: String! author: Author category: Category }
type Query { articles(first: Int = 20): [Article!]! }
`;
const resolvers = {
Query: {
articles: async (_: unknown, { first }: { first: number }) => {
const res = await fetch(`${process.env.CMS_REST_URL}/articles?limit=${Math.min(first, 100)}`, {
headers: { Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN}` },
});
return (await res.json()).items;
},
},
Article: {
author: (a: { authorId?: string }, _: unknown, ctx: { loaders: Loaders }) => (a.authorId ? ctx.loaders.author.load(a.authorId) : null),
category: (a: { categoryId?: string }, _: unknown, ctx: { loaders: Loaders }) => (a.categoryId ? ctx.loaders.category.load(a.categoryId) : null),
},
};
export const yoga = createYoga({
schema: createSchema({ typeDefs, resolvers }),
context: () => ({ loaders: createLoaders() }), // fresh loaders, and a fresh cache, for every request
});
With this in place, the homepage query makes one request for articles, one for all authors and one for all categories, regardless of how many cards it shows. Nested levels batch too: if categories had parent categories, the parents would be loaded in one more batch at the next level.
When the CMS has no batch endpoint
Most CMS delivery APIs accept a list of ids through a filter such as sys.id[in], filters[id][$in] or an ids parameter. Where none exists, the batch function can still deduplicate ids and fetch them with limited concurrency. That does not reduce the number of requests as much, but deduplication alone often halves them, and a concurrency limit protects the rate limit. Alternatively, ask the CMS to include references in the list request, the REST equivalent of a join, and prime the loaders with the included objects using loader.prime(id, value), so resolvers find them without any extra request.
Configuration Reference
| Option | Recommended value | Why |
|---|---|---|
| Loader lifetime | one per request | Prevents cross-request data leaks and stale results. |
maxBatchSize |
the CMS’s maximum ids per request | Larger batches are split automatically. |
cache |
enabled (default) | Deduplicates repeated ids within the request. |
| Missing entries | return null in key order |
Unpublished references do not fail the whole batch. |
| Batch errors | throw for transport failures | Every waiting resolver receives the error. |
| Priming | prime from included references | Avoids a second request for data the list already returned. |
Gotchas & Edge Cases
- Order mismatch. Returning the CMS’s result array directly, in the CMS’s order, silently assigns authors to the wrong articles. Always map results back to the key order.
- Locale and preview in keys. If the same resolver serves several locales or preview and published content, include them in the key or create separate loaders, or a German request can receive English data cached by the same loader.
- Loaders in module scope. A loader created at module level works in development and leaks data between users in production. Create loaders only in the context factory.
- Batching across await boundaries. DataLoader batches calls made in the same tick. A resolver that awaits something before calling
loadcan end up in a separate batch. Callloadfirst, then await.
Worked Example
The media company added loaders for authors, categories and assets, primed from the list request where the CMS could include references, and set maxBatchSize to the API’s limit of 100. The homepage query fell from 121 CMS requests to 3, and its median latency from 900 to 160 milliseconds. During the next traffic spike, the BFF stayed well inside the rate limit, and the error-rate alert that had fired during the previous spike stayed quiet.
Finding N+1 Patterns Before They Reach Production
N+1 problems are easy to prevent once they are visible. Instrument the CMS client in the GraphQL layer so each request records the operation name of the GraphQL query that caused it, and chart requests per operation. An operation whose request count grows with the size of the list it returns is an N+1 candidate. In tests, run representative queries against recorded fixtures and assert an upper bound on CMS calls per query, for example “the homepage query makes at most five CMS requests”. Such a test fails the moment someone adds a new field resolver without a loader, which is exactly when the problem is cheapest to fix. Code review can help too: any resolver that calls the CMS client directly with an id taken from its parent object, instead of going through a loader, deserves a question.
Rollout Checklist
- Find resolvers that fetch a reference by id from their parent object.
- Add a batch endpoint call per reference type and wrap it in a DataLoader.
- Create loaders per request in the context factory, with locale and preview in the key where needed.
- Prime loaders from references included in list responses.
- Log CMS requests per GraphQL operation and assert upper bounds in tests.
Frequently Asked Questions
Does the CMS’s own GraphQL API have N+1 problems?
Not from your side; it resolves references internally and counts complexity instead. The N+1 pattern appears in GraphQL layers you build on top of REST APIs or across several sources.
Is DataLoader only for GraphQL?
No. It works in any code that makes many single-item lookups in the same tick, such as server components rendering lists, but GraphQL resolvers are where the pattern is most common.
Should loaders cache across requests to save more calls?
No. Use a separate shared cache, such as the framework’s data cache or a CDN, with proper invalidation. DataLoader’s cache is for deduplication within one request.
How does this interact with federation?
Federation’s _entities requests already arrive batched, so a subgraph’s reference resolver receives many representations at once. Use a loader inside it to fetch them from the CMS in one request, as in the federation v2 guide.