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.

Twenty articles, twenty-one requestsA query for twenty articles with their authors makes one request for the article list and then one request per article for its author, because the author resolver runs once per article.ClientYour GraphQL layerCMS REST APIarticles(first: 20) { title author { name } }GET /articles?limit=2020 articlesGET /authors/a1GET /authors/a2... 18 moreauthor requestsresponse after 21 requests
The resolver is correct for one article; the executor calls it twenty times in a row.

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.

Batching reference lookups per requestResolvers for twenty articles call the author loader with each author id; the loader deduplicates the ids and calls the batch function once, which fetches all authors with one CMS request and returns them in key order.20 author resolversload(authorId)DataLoadercollect + dedupeBatch function5 unique idsCMSGET /authors?ids=20 calls1 batch1 request
Resolvers stay simple; the loader turns twenty calls into one request with five unique ids.

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.

TypeScript
// 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>;
TypeScript
// 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 load can end up in a separate batch. Call load first, 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.

CMS requests for the homepage queryNumber of CMS API requests made by the BFF for one homepage query with 40 article cards, before batching, with DataLoader batching, and with batching plus priming from included references.Per-item resolvers121 requests per queryDataLoader batching4 requests per queryBatching + priming3 requests per query
Batching removed almost all requests; priming removed one more round trip.

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.