Skip to the solution
MediumBackend7 min read

How do you build a GraphQL API in Node.js, and what is the N+1 problem?

130 views

The problem

Question presented to candidate: "You have added a GraphQL layer over your Node service. A list of 20 posts is suddenly making 21 database queries. What is happening, and how do you fix it?"

What a strong answer should cover:

  • A minimal GraphQL server needs three things: a schema (the types and what fields exist), resolvers (functions that produce each field's value), and something to execute a query against them β€” graphql-js directly, or a server layer like Apollo Server / graphql-yoga on top.
  • A resolver runs independently, per field, per object in the result set. A naive Post.author resolver that does its own database lookup runs once for every post returned, not once for the whole list.
  • πŸ“Œ The N+1 problem: 1 query to fetch N posts, then N more queries β€” one per post β€” to fetch each post's author. Even when several posts share the same author, a naive resolver does not know that and repeats the lookup.
  • DataLoader is the standard fix: it does not change the resolver's call site (authorLoader.load(id) still looks like one call per post), but it collects every .load() call made within the same event-loop tick, deduplicates the ids, and issues one batched call for all of them.
  • The batching is per-request, not global β€” a fresh DataLoader instance per incoming GraphQL request (created in request context) is the standard pattern; a shared long-lived loader risks serving stale or cross-request-leaked cached values.
  • This is a general resolver-composition problem, not specific to any one GraphQL library β€” REST code that loops and queries per item has the exact same shape of bug; GraphQL just makes it easy to trigger by accident because nested field selection is the whole point of the query language.

Clarifying questions expected:

  • "Is the N+1 happening on every field, or specifically on associations/relations resolved per item?" β€” narrows it to the resolver doing the per-item lookup.
  • "Is a DataLoader instance shared across requests, or created fresh per request?" β€” a shared instance is a correctness risk (stale/cross-user data), not just a performance one.

Code / implementation expected: Yes β€” showing the naive resolver, then the DataLoader-batched version, with the call counts, is the clearest way to make the fix concrete.

  • graphql
  • n+1
  • dataloader
  • resolvers
  • api-design

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read Β· 8 sections

Read the code

JavaScriptNaive per-post resolver (3 calls) vs DataLoader-batched resolver (1 call), same query
import { graphql, buildSchema } from "graphql";
import DataLoader from "dataloader";

const schema = buildSchema(`
  type Author { id: ID!, name: String! }
  type Post { id: ID!, title: String!, author: Author! }
  type Query { posts: [Post!]! }
`);

const posts = [
  { id: "1", title: "A", authorId: "a1" },
  { id: "2", title: "B", authorId: "a2" },
  { id: "3", title: "C", authorId: "a1" },
];
const authorsById = { a1: { id: "a1", name: "Ada" }, a2: { id: "a2", name: "Grace" } };
const query = "{ posts { id title author { id name } } }";

// Naive: one lookup per post, no dedup.
let naiveCalls = 0;
const rootNaive = {
  posts: () => posts.map((p) => ({
    ...p,
    author: async () => { naiveCalls++; return authorsById[p.authorId]; },
  })),
};
await graphql({ schema, source: query, rootValue: rootNaive });
console.log("naive calls:", naiveCalls);
// naive calls: 3

// DataLoader: one call, deduped ids.
let batchCalls = 0;
const authorLoader = new DataLoader(async (ids) => {
  batchCalls++;
  console.log("batched ids:", ids); // ["a1", "a2"]
  return ids.map((id) => authorsById[id]);
});
const rootLoader = {
  posts: () => posts.map((p) => ({ ...p, author: () => authorLoader.load(p.authorId) })),
};
await graphql({ schema, source: query, rootValue: rootLoader });
console.log("batched calls:", batchCalls);
// batched ids: [ 'a1', 'a2' ]
// batched calls: 1

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 109 of 152 in Node.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Node.js questions
Up nextMedium

What are 'unhandledRejections' and how do they differ from 'uncaughtExceptions'?

Continue