Advanced GraphQL
The N+1 query problem and DataLoader, Relay-style cursor pagination, and subscriptions.
The N+1 query problem
This is the single most common performance trap in GraphQL APIs, and it follows directly from how resolvers work. Recall from the previous page: a field like Post.author gets its own resolver, called once per post object the query touches.
Consider this query, fetching 50 posts and each one's author's name:
query {
posts(limit: 50) {
title
author {
name
}
}
}
With the naive resolvers from the previous page:
const resolvers = {
Query: {
posts: (parent, args, context) => context.db.posts.findMany({ limit: args.limit }),
},
Post: {
author: (post, args, context) => context.db.authors.findById(post.authorId),
},
};
Here's what actually happens against the database:
1 query: SELECT * FROM posts LIMIT 50
50 queries: SELECT * FROM authors WHERE id = ? (once per post, sequentially or in parallel)
One query to fetch the posts, plus N (50) separate queries to fetch each post's author individually — that's the "N+1" problem. It's easy to miss in development with a handful of test rows, and devastating in production once a list grows to hundreds of items: a single client request can silently trigger hundreds of individual database round-trips.
DataLoader: batching and caching the fix
DataLoader (originally built by Facebook alongside GraphQL) solves this by batching: instead of each Post.author resolver call immediately hitting the database, DataLoader collects all the individual keys requested during a single tick of the event loop, and issues one batched call for all of them together.
import DataLoader from 'dataloader';
// batchLoadAuthors receives ALL the author IDs requested this tick, at once
async function batchLoadAuthors(authorIds) {
const authors = await db.authors.findByIds(authorIds); // ONE query: WHERE id IN (...)
// DataLoader requires the returned array to be in the SAME order as the input keys
const authorsById = new Map(authors.map(a => [a.id, a]));
return authorIds.map(id => authorsById.get(id));
}
// Create one DataLoader per request (never share across requests/users!)
function createLoaders() {
return {
authorLoader: new DataLoader(batchLoadAuthors),
};
}
const resolvers = {
Post: {
author: (post, args, context) => context.loaders.authorLoader.load(post.authorId),
},
};
With this in place, the same 50-post query now results in:
1 query: SELECT * FROM posts LIMIT 50
1 query: SELECT * FROM authors WHERE id IN (7, 12, 7, 19, 7, ...) -- deduplicated & batched
DataLoader also caches within a single request — if the same author ID is requested twice while resolving different posts (a common case, since many posts share an author), it's only fetched once and the cached result is reused for the second .load() call. A fresh set of loaders should be created per request (usually placed on context), never shared across requests — caching an author's data across different users' requests would leak stale or cross-request data.
Relay-style cursor pagination
GraphQL doesn't mandate a specific pagination style, but a widely-adopted convention — originating from Facebook's Relay client — structures paginated lists as connections, wrapping each item in an edge that carries a cursor:
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
cursor: String!
node: Post!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
type Query {
posts(first: Int, after: String): PostConnection!
}
query {
posts(first: 10, after: "cursor_abc") {
edges {
cursor
node {
title
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
The extra edges/node/cursor nesting looks verbose compared to a flat [Post!]! list, but it earns its keep: pageInfo.hasNextPage tells the client whether to keep paging without an extra request, and endCursor is exactly the opaque value to pass as after on the next request — the same cursor-based approach covered for REST APIs elsewhere in this catalog, just expressed as a GraphQL type shape instead of query-string parameters.
Subscriptions, briefly
Subscriptions are GraphQL's third operation type (alongside query and mutation), for cases where the client needs to be pushed updates in real time rather than repeatedly asking for them:
type Subscription {
postCommentAdded(postId: ID!): Comment!
}
subscription {
postCommentAdded(postId: "123") {
id
body
author {
name
}
}
}
Under the hood, a subscription typically stays open over a WebSocket connection (see the WebSockets track for the transport mechanics) — the client opens one long-lived connection and the server pushes a new payload every time the subscribed event occurs, rather than the client polling repeatedly. Subscriptions are the right tool for genuinely event-driven features (live comment feeds, notifications, collaborative editing indicators); most ordinary reads should still be plain queries.
Common mistakes
- Writing naive per-item resolvers (like
Post.authorfetching one row at a time) without DataLoader, and only discovering the N+1 problem once production data volumes make it painfully slow. - Sharing a single DataLoader instance across multiple requests/users instead of creating a fresh one per request — this leaks cached data across requests that shouldn't share a cache.
- Reaching for subscriptions for data that would be perfectly well served by a normal query the client re-fetches occasionally — subscriptions add real infrastructure complexity (persistent connections, a pub/sub backend) that isn't free.
Interview questions
Q: What is the N+1 query problem in GraphQL, concretely? It's when resolving a list of N parent objects (say, 50 posts) triggers N separate additional queries to resolve a related field on each one individually (like each post's author) — instead of 1 batched query for all of them, you get 1 (for the list) + N (one per item) = N+1 total queries, which scales poorly as the list grows.
Q: How does DataLoader fix the N+1 problem?
It batches individual .load(key) calls that occur within the same tick of the event loop into a single batch function call, which is expected to fetch all the requested keys in one underlying query (e.g., WHERE id IN (...)) instead of one query per key. It also caches results per request, so requesting the same key twice while resolving different fields only hits the batch function once.
Q: What are GraphQL subscriptions, and when should you actually use them? Subscriptions are a third operation type (alongside queries and mutations) that let a client receive a stream of pushed updates over a persistent connection (typically a WebSocket), rather than repeatedly polling with queries. They're appropriate for genuinely real-time features — live notifications, chat, collaborative editing — but add real infrastructure complexity, so they shouldn't be reached for when an occasional re-query would do.