Skip to main content

03 - GraphQL: Query Language, Resolver and the N+1 Problem

GraphQL removes over-fetching, but every field has a resolver: 10 users with orders become 211 queries. With DataLoader they drop to 4, measured.

What do you think about this article?

03 - GraphQL: Query Language, Resolver and the N+1 Problem
API DesignPhoto:Stanislav KondratievPexels

At a Glance

GraphQL removes over-fetching, but every field has a resolver: 10 users with orders become 211 queries. With DataLoader they drop to 4, measured.

With a REST API the client receives all the fields of a resource even when it needs two, and for a complete profile with orders and products it often makes more requests. GraphQL removes this problem: the client writes a single query in which it lists the fields it wants, and the server builds the response by resolving one field at a time. The price is a structural flaw that hits almost every first GraphQL server: the N+1 problem. A query that looks harmless, "ten users with their orders, lines and products", can become 211 database queries, as I measure below; with DataLoader, the same query costs 4.

This article is the third in the series: the comparison between protocols is in the API landscape and the REST counterpart in REST in 2026. All the listings were run with Node 24 and Apollo Server 5, without opening ports: the server is queried in process and the "database" is an in-memory object that records every query as a SQL log would. The numbers you read are therefore counts, reproducible with the same files, and not timings.

The schema: the typed contract

Every GraphQL API starts from a schema written in SDL (Schema Definition Language): it lists the types, the fields of each and the entry points, Query for reads and Mutation for writes. The delicate point is the exclamation mark: String! means "never null", [Order!]! a non-null list of non-null orders. In the schema below User.orders is non-null while User.profile is nullable, because not all users have a profile. This distinction has a consequence for errors, which you will see in the last section. I left out Subscription (event streams), which require a separate transport and are not covered here.

# schema.graphql
type User {
  id: ID!
  name: String!
  email: String!
  orders: [Order!]!       # a user has many orders
  profile: UserProfile    # nullable: not everyone has a profile
}

type Order {
  id: ID!
  total: Float!
  status: OrderStatus!
  items: [OrderItem!]!
  user: User!             # inverse relation: from order to user
}

type OrderItem {
  id: ID!
  quantity: Int!
  product: Product!
}

type Product {
  id: ID!
  name: String!
  price: Float!
}

type UserProfile {
  bio: String
  website: String
}

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
}

type Query {
  user(id: ID!): User
  users(limit: Int = 20, offset: Int = 0): [User!]!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
}

input CreateUserInput {
  name: String!
  email: String!
}

# Output (node schema-check.ts):
# Query fields     : user, users
# User fields      : id, name, email, orders, profile
# User.profile is non-null? false | User.orders is non-null? true

The output at the end comes from a short check script (not shown) that loads the file with buildSchema from the graphql library: if a type were missing, for example an input used but not defined, or the syntax were wrong, it would throw an error. Instead it prints the fields of Query and User and confirms that profile is nullable and orders is not. Note also that comments in SDL start with #, not with //.

How GraphQL executes a query

For every requested field GraphQL calls a function, the resolver, which receives four arguments: the parent object (the result of the field that contains the current one), the arguments of the query, the context shared by the request (database, authenticated user, loaders) and information about the query. Simple fields, such as User.name, do not need a resolver: the value is read from the parent object. A resolver is needed where the data must be fetched elsewhere, as in User.orders.

In the listing every resolver adds a line to trace before working. The query asks for two users with their orders, so you expect one call for the root and one for each user, which is exactly what the output shows. It is also the first chance to see the flaw: User.orders is called once per user.

// trace.ts - in what order does GraphQL call the resolvers? We find out by recording every call
import { ApolloServer } from "@apollo/server";
import { readFileSync } from "node:fs";
import { seed, type Db } from "./db.ts";

const typeDefs = readFileSync("./schema.graphql", "utf8");
type Ctx = { db: Db; trace: string[] };

const resolvers = {
  Query: {
    users: (_: unknown, { limit }: { limit: number }, { db, trace }: Ctx) => {
      trace.push(`Query.users(limit: ${limit})`);
      return db.findUsers(limit);
    },
  },
  User: {
    orders: (user: { id: string }, _: unknown, { db, trace }: Ctx) => {
      trace.push(`User.orders   parent=${user.id}`);
      return db.findOrdersByUserIds([user.id]);
    },
  },
};

const server = new ApolloServer<Ctx>({ typeDefs, resolvers });
await server.start();
const trace: string[] = [];
const query = `{ users(limit: 2) { id name orders { id total } } }`;
const res = await server.executeOperation({ query }, { contextValue: { db: seed(), trace } });
if (res.body.kind !== "single") throw new Error("unexpected response");
console.log(trace.join("\n"));
const first = (res.body.singleResult.data as any).users[0];
console.log(`first user: ${first.name}, orders received: ${first.orders.length}`);
await server.stop();

// Output (node trace.ts):
// Query.users(limit: 2)
// User.orders   parent=u1
// User.orders   parent=u2
// first user: User 1, orders received: 5

The specification says that the executor may execute the fields of a selection "in any order it sees fit (normally in parallel)", because the resolution of read fields must be free of side effects (section 6.3.1), while the root fields of a mutation are executed serially (section 6.2.2). For you this means two things: never rely on the call order of read resolvers, and never put side effects in a Query resolver.

The N+1 problem, measured

The name comes from the count: one query to get the list (the "1") and one query for each of its N elements (the "N"). In the listing the relational resolvers query the database directly: User.orders once per user, Order.items once per order, OrderItem.product once per line. The data is ten users with five orders each, every order with three lines, over fifteen distinct products. The last line prints, with db.report(), how many queries each table saw.

// naive.ts - resolvers WITHOUT DataLoader: every relational field queries the database
import { ApolloServer } from "@apollo/server";
import { readFileSync } from "node:fs";
import { seed, type Db } from "./db.ts";

const typeDefs = readFileSync("./schema.graphql", "utf8");
type Ctx = { db: Db };

const resolvers = {
  Query: {
    users: (_: unknown, { limit }: { limit: number }, { db }: Ctx) => db.findUsers(limit),
  },
  User: {
    orders: async (user: { id: string }, _: unknown, { db }: Ctx) =>
      db.findOrdersByUserIds([user.id]), // one query for EACH user
  },
  Order: {
    items: async (order: { id: string }, _: unknown, { db }: Ctx) =>
      db.findItemsByOrderIds([order.id]), // one query for EACH order
  },
  OrderItem: {
    product: async (item: { productId: string }, _: unknown, { db }: Ctx) =>
      (await db.findProductsByIds([item.productId]))[0], // one query for EACH line
  },
};

const query = `{ users(limit: 10) { name orders { total items { quantity product { name } } } } }`;

const server = new ApolloServer<Ctx>({ typeDefs, resolvers });
await server.start();
const db = seed();
const res = await server.executeOperation({ query }, { contextValue: { db } });
if (res.body.kind !== "single") throw new Error("unexpected response");
console.log("errors:", res.body.singleResult.errors ?? "none");
for (const line of db.report()) console.log(line);
await server.stop();

// Output (node naive.ts):
// errors: none
// total: 211
// users: 1
// orders: 10
// order_items: 50
// products: 150

The count adds up: 1 + 10 + 50 + 150 = 211. Each level of nesting multiplies the previous one, so with a hundred users the queries become 2,101 without the GraphQL query changing by a comma. In a real database every trip costs a network round trip and a place in the connection pool queue, and here the "innocent" shape of the query deceives: from the client's point of view it is a single request. The problem is not specific to GraphQL (any loop that queries the database inside another loop produces it), but GraphQL makes it easy because it is the resolvers, called one per field, that hide the loop.

DataLoader: group and remember

DataLoader solves the problem with two mechanisms. Batching collects all the calls to load(key) made in the same "round" of the event loop and invokes the loading function once with all the keys. Memoization remembers the result of every key, so the same key requested twice costs a single read. It comes from a Facebook project from 2010, and is now maintained under the graphql GitHub organisation, as its README recounts.

The contract of the loading function is rigid and worth remembering: it receives an array of keys and must return a promise of an array of the same length and in the same order. This is why the listing has the helper groupBy, which groups rows by key and then walks the original keys, inserting an empty list where there is no order, and productById which returns undefined for an unknown id. The point that makes the difference is createLoaders(db): it is a function, and it must be called once for every request.

// loaders.ts - one DataLoader per relation, created ONCE PER REQUEST
import DataLoader from "dataloader";
import type { Db, Order, OrderItem, Product } from "./db.ts";

// Groups the rows by key and returns an array in the same order as the keys
function groupBy<T>(rows: T[], keyOf: (row: T) => string, keys: readonly string[]): T[][] {
  const map = new Map<string, T[]>();
  for (const row of rows) map.set(keyOf(row), [...(map.get(keyOf(row)) ?? []), row]);
  return keys.map((k) => map.get(k) ?? []);
}

export function createLoaders(db: Db) {
  return {
    ordersByUserId: new DataLoader<string, Order[]>(async (userIds) =>
      groupBy(await db.findOrdersByUserIds(userIds), (o) => o.userId, userIds)),

    itemsByOrderId: new DataLoader<string, OrderItem[]>(async (orderIds) =>
      groupBy(await db.findItemsByOrderIds(orderIds), (i) => i.orderId, orderIds)),

    productById: new DataLoader<string, Product | undefined>(async (ids) => {
      const byId = new Map((await db.findProductsByIds(ids)).map((p) => [p.id, p]));
      return ids.map((id) => byId.get(id)); // same order, same length as the keys
    }),
  };
}
export type Loaders = ReturnType<typeof createLoaders>;

In the next listing the resolvers no longer call the database: they call loaders.ordersByUserId.load(user.id) and the like. The query is exactly the same as before, and the context of each request contains a new set of loaders. The output, besides the count, prints the SQL queries actually issued.

// batched.ts - the same query as naive.ts, with the resolvers going through the DataLoaders
import { ApolloServer } from "@apollo/server";
import { readFileSync } from "node:fs";
import { seed, type Db } from "./db.ts";
import { createLoaders, type Loaders } from "./loaders.ts";

const typeDefs = readFileSync("./schema.graphql", "utf8");
type Ctx = { db: Db; loaders: Loaders };

const resolvers = {
  Query: {
    users: (_: unknown, { limit }: { limit: number }, { db }: Ctx) => db.findUsers(limit),
  },
  User: {
    orders: (user: { id: string }, _: unknown, { loaders }: Ctx) => loaders.ordersByUserId.load(user.id),
  },
  Order: {
    items: (order: { id: string }, _: unknown, { loaders }: Ctx) => loaders.itemsByOrderId.load(order.id),
  },
  OrderItem: {
    product: (item: { productId: string }, _: unknown, { loaders }: Ctx) => loaders.productById.load(item.productId),
  },
};

const query = `{ users(limit: 10) { name orders { total items { quantity product { name } } } } }`;

const server = new ApolloServer<Ctx>({ typeDefs, resolvers });
await server.start();
const db = seed();
const res = await server.executeOperation({ query }, { contextValue: { db, loaders: createLoaders(db) } });
if (res.body.kind !== "single") throw new Error("unexpected response");
console.log("errors:", res.body.singleResult.errors ?? "none");
for (const line of db.report()) console.log(line);
console.log(db.queries.join("\n"));
await server.stop();

// Output (node batched.ts):
// errors: none
// total: 4
// users: 1
// orders: 1
// order_items: 1
// products: 1
// SELECT * FROM users LIMIT 10
// SELECT * FROM orders WHERE user_id IN (10 id)
// SELECT * FROM order_items WHERE order_id IN (50 id)
// SELECT * FROM products WHERE id IN (15 id)

From 211 queries to 4: one per table. Two details deserve attention. The first is order_items WHERE order_id IN (50 id): the fifty keys are the fifty orders, all collected in the same round. The second is products WHERE id IN (15 id): the lines are 150, but the distinct products 15, and memoization reduced the keys to fifteen before calling the function. Note that I added a loader for the order lines, itemsByOrderId, which is easy to forget: if Order.items still queried the database directly, 50 queries would remain, one per order.

How DataLoader decides when to group

Batching depends on a precise rule: according to the README, DataLoader gathers all the load() calls that happen "within a single frame of execution (a single tick of the event loop)" and then calls the function with the collected keys. The listing tries four situations, each with a practical consequence, and prints the real effect of each.

// ticks.ts - four experiments on DataLoader's behaviour
import DataLoader from "dataloader";

const nomi: Record<string, string> = { u1: "Anna", u2: "Bruno", u3: "Chiara" };
const batches: string[][] = [];
const nuovo = () =>
  new DataLoader<string, string>(async (keys) => {
    batches.push([...keys]);
    return keys.map((k) => nomi[k]);
  });

// 1. Three load() calls in the same "tick" of the event loop: a single batch, and the repeated key counts once
let loader = nuovo();
await Promise.all([loader.load("u1"), loader.load("u2"), loader.load("u1")]);
console.log("same tick        ->", JSON.stringify(batches));

// 2. A load() after waiting on a timer: the window has closed, new batch
batches.length = 0;
loader = nuovo();
const a = loader.load("u1");
const b = new Promise<string>((ok) => setTimeout(() => ok(loader.load("u2")), 0));
await Promise.all([a, b]);
console.log("different ticks  ->", JSON.stringify(batches));

// 3. A batch function that returns fewer results than keys
const rotto = new DataLoader<string, string>(async (keys) => [nomi[keys[0]]]);
try {
  await Promise.all([rotto.load("u1"), rotto.load("u2")]);
} catch (e) {
  console.log("wrong length     ->", (e as Error).message);
}

// 4. The cache of a loader shared between requests serves the old data
nomi.u1 = "Anna";
const condiviso = nuovo();
console.log("request 1        ->", await condiviso.load("u1"));
nomi.u1 = "Anna Maria";
console.log("request 2        ->", await condiviso.load("u1"), "(old data)");
console.log("new loader       ->", await nuovo().load("u1"));

// Output (node ticks.ts):
// same tick        -> [["u1","u2"]]
// different ticks  -> [["u1"],["u2"]]
// wrong length     -> DataLoader must be constructed with a function which accepts Array<key> and returns Promise<Array<value>>, but the function did not return a Promise of an Array of the same length as the Array of keys.
//
// Keys:
// u1,u2
//
// Values:
// Anna
// request 1        -> Anna
// request 2        -> Anna (old data)
// new loader       -> Anna Maria

The first two lines show the rule: three load() calls in the same tick produce a single batch, in which the repeated key appears only once, while a load() executed after a setTimeout arrives when the window is closed and generates a second batch. In a resolver this happens when the code waits for a timer before loading: the grouping is lost without errors, and you notice only by counting queries. The third line is DataLoader's real message when the function returns fewer values than keys: the error is clear, and confirms that the "same length, same order" contract is checked at runtime. The last experiment explains why loaders are created per request: a shared loader keeps serving "Anna" even after the data has changed to "Anna Maria". The README says so explicitly, recommending a new DataLoader per request because a cache shared among users with different permissions can be dangerous.

Errors and defences: what you really see

GraphQL does not use HTTP status codes as REST does to say what went wrong. The final listing queries Apollo Server at the HTTP level (with executeHTTPGraphQLRequest, still without ports) in four cases: a simple query without authentication, the same one with a user, a query that is too deep and one that is too expensive. The last two are blocked by validation rules: depthLimit(3) from the graphql-depth-limit library and createComplexityLimitRule(60) from graphql-validation-complexity. The numbers 3 and 60 are chosen for this test schema, they are not recommended values.

// guard.ts - errors in GraphQL and defences against queries that are too deep
import { ApolloServer, HeaderMap } from "@apollo/server";
import { GraphQLError } from "graphql";
import depthLimit from "graphql-depth-limit";
import { createComplexityLimitRule } from "graphql-validation-complexity";
import { readFileSync } from "node:fs";
import { seed, type Db } from "./db.ts";

const typeDefs = readFileSync("./schema.graphql", "utf8");
type Ctx = { db: Db; currentUser: { id: string } | null };

const resolvers = {
  Query: {
    users: (_: unknown, { limit }: { limit: number }, { db, currentUser }: Ctx) => {
      if (!currentUser) {
        throw new GraphQLError("Authentication required", { extensions: { code: "UNAUTHENTICATED" } });
      }
      return db.findUsers(Math.min(limit, 100)); // safety cap on the number of rows
    },
  },
};

const server = new ApolloServer<Ctx>({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(3), // at most 3 levels of nesting under the root
    createComplexityLimitRule(60, { formatErrorMessage: (cost: number) => `Query too expensive (${cost}), maximum 60` }),
  ],
});
await server.start();

async function post(query: string, currentUser: Ctx["currentUser"]) {
  const r = await server.executeHTTPGraphQLRequest({
    httpGraphQLRequest: {
      method: "POST",
      headers: new HeaderMap([["content-type", "application/json"]]),
      search: "",
      body: { query },
    },
    context: async () => ({ db: seed(), currentUser }),
  });
  const body = JSON.parse(r.body.kind === "complete" ? r.body.string : "{}");
  return { status: r.status ?? 200, body };
}

const semplice = `{ users(limit: 2) { name } }`;
const profonda = `{ users(limit: 2) { orders { items { product { name } } } } }`;
const costosa = `{ users(limit: 2) { orders { total items { quantity } } } }`;

let r = await post(semplice, null);
console.log("anonymous, simple query :", r.status, r.body.errors[0].extensions.code, "data =", r.body.data);
r = await post(semplice, { id: "u1" });
console.log("authenticated, simple   :", r.status, JSON.stringify(r.body));
r = await post(profonda, { id: "u1" });
console.log("authenticated, deep     :", r.status, r.body.errors?.[0]?.message ?? "ok");
r = await post(costosa, { id: "u1" });
console.log("authenticated, expensive:", r.status, r.body.errors?.[0]?.message ?? "ok");
await server.stop();

// Output (node guard.ts):
// anonymous, simple query : 200 UNAUTHENTICATED data = null
// authenticated, simple   : 200 {"data":{"users":[{"name":"User 1"},{"name":"User 2"}]}}
// authenticated, deep     : 400 '' exceeds maximum operation depth of 3
// authenticated, expensive: 400 Query too expensive (1100), maximum 60

The first case is the most instructive. The resolver throws a GraphQLError with the code UNAUTHENTICATED, and the result has HTTP status 200 (Apollo leaves the status undefined, which HTTP reads as 200: the listing prints it with r.status ?? 200) and data = null. This is not an oversight: users is declared [User!]!, so the error on a non-null field propagates to the parent, and since all the fields up to the root are non-null, the specification prescribes that the whole data entry be null (section 6.4.4). In practice a client cannot rely on the HTTP code: it must look at the errors list and its extensions.code. The last two cases, instead, fail in validation, before any resolver runs, and Apollo answers 400.

There is also a surprise in the last case: the query asks for users(limit: 2), but the rule assigns it a cost of 1,100, because it estimates the cost from the schema (in the library's README the factor for a list is 10 by default) and not from the value of the arguments, as the 1,100 with limit: 2 shows. A complexity analysis is therefore prudent by construction, and must be tuned on your clients' real queries. On introspection, Apollo Server's code enables it if NODE_ENV is not production (config.introspection ?? isDev): if needed, you can force it with the introspection option. Hiding the schema reduces exposure, but it is not a defence: the real control remains authorisation in the resolvers, field by field.

When GraphQL is not the right choice

If your API has a single client and simple resources, the flexibility of GraphQL is paid for in resolvers, loaders, depth and cost limits to maintain. If responses must be cached by a CDN, remember that GraphQL over POST does not allow it out of the box, and that the GraphQL-over-HTTP draft allows GET for queries only: the discussion is in the API landscape. For a more extensive comparison of the trade-offs read GraphQL vs REST: real-world trade-offs, and to see GraphQL used as the API of a complete product, Saleor.

What you can do now

You can read an SDL schema and say which fields are non-null and why it matters; you know what a resolver does and with which four arguments; you can recognise the N+1 problem from the count of queries (1 + 10 + 50 + 150 = 211 in our example) and solve it with one loader per relation, created per request, that preserves the order of the keys. You also know that a GraphQL error often arrives with status 200, that validation blocks queries that are too deep or too expensive before any resolver starts, and that introspection is a choice of exposure, not of security. The natural step, when there is more than one service, is to compose several schemas into one; here you stop at one schema and one server that behave well.

Frequently asked questions

What is the N+1 problem in GraphQL?

It is the case in which, to list N elements and a relation of theirs, the server makes one query for the list and one for each of the N elements. In the example on this page ten users with orders, lines and products produce 211 queries; with one DataLoader per relation they drop to 4.

Is a DataLoader created once per application or per request?

Per request. Its cache serves only to avoid repeated reads within a single request: a loader shared between requests serves old data (the "Anna" / "Anna Maria" experiment shows it) and can show one user the data of another with different permissions.

Does GraphQL always answer with status 200, even when there is an error?

It depends on the error. With Apollo Server 5.5.2 an error thrown by a resolver arrives with status 200 and an errors list in the body, while a query rejected in validation (too deep or too expensive) gets 400. This is why the client must always read errors and not trust the HTTP code alone.

Sources

The seven listings were run with Node 24.11.0, @apollo/server 5.5.2, graphql 16.15.0, dataloader 2.2.3, graphql-depth-limit 1.1.0 and graphql-validation-complexity 0.4.2, installed in a temporary folder. The output shown is identical across two consecutive runs. The "database" is an in-memory object that records the queries: the numbers are query counts, not timings of a real database.

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects