Pagination, Errors and N+1 Problem

advanced
13 min

Pagination, Errors and the N+1 Problem

Your GraphQL API works, but production traffic exposes three challenges every GraphQL developer eventually meets: returning long lists without sending everything at once, reporting errors in a useful way, and avoiding the infamous N+1 query problem that can quietly hammer your database. This lesson tackles all three.

Pagination: Offset vs Cursor

The simplest approach is offset pagination, familiar from SQL:

graphql
type Query { posts(limit: Int = 10, offset: Int = 0): [Post!]! }

It is easy to implement but has drawbacks: results shift if rows are inserted while a user is paging, and large offsets get slow. Cursor pagination fixes both by anchoring each page to a stable identifier. The widely used Relay connection style looks like this:

graphql
type Query { posts(first: Int!, after: String): PostConnection! } type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! } type PostEdge { node: Post! cursor: String! } type PageInfo { hasNextPage: Boolean! endCursor: String }

The client asks for first: 10, receives an endCursor, and passes it as after to fetch the next page. Use offset pagination for simple admin tables; use cursors for infinite scrolling feeds and any list that changes frequently.

Handling Errors Properly

GraphQL responses can contain both data and errors at the same time, because one field can fail while others succeed:

json
{ 'data': { 'user': null }, 'errors': [{ 'message': 'User not found', 'path': ['user'] }] }

Throwing raw exceptions from resolvers leaks stack traces and gives clients nothing to branch on. Instead, attach a machine-readable code:

js
import { GraphQLError } from 'graphql' throw new GraphQLError('User not found', { extensions: { code: 'NOT_FOUND' } })

For expected outcomes like validation failures, many teams go further and model errors in the schema itself, returning a union of Success and typed error results so the possibilities are visible in the type system rather than hidden in an error array.

The N+1 Problem

Consider this query:

graphql
query { posts(first: 20) { edges { node { title author { name } } } } }

A naive author resolver runs one database query per post: one query for the posts plus twenty for the authors. That is 1 + N queries, and it grows with every page size increase.

Solving N+1 with DataLoader

DataLoader batches all the individual lookups that happen in one tick of the event loop into a single query:

js
import DataLoader from 'dataloader' const authorLoader = new DataLoader(async (ids) => { const rows = await db.users.findMany({ where: { id: { in: ids } } }) const byId = new Map(rows.map(r => [r.id, r])) return ids.map(id => byId.get(id)) }) const resolvers = { Post: { author: (post, args, context) => context.authorLoader.load(post.authorId) } }

Twenty load calls collapse into one WHERE id IN (...) query. Two rules matter: the batch function must return results in the same order as the input ids, and you should create loaders per request (in the context factory) so one user's cache never leaks into another user's response.

Key Takeaways

  • Offset pagination is simple; cursor-based connections are stable and scale better for feeds.
  • GraphQL can return partial data plus an errors array; add extensions.code for machine-readable errors.
  • The N+1 problem means one parent query triggers N child queries through nested resolvers.
  • DataLoader batches and caches lookups per request, collapsing N queries into one.
  • Create DataLoader instances per request to avoid cross-user cache leaks.

In the final lesson we zoom out and cover GraphQL Best Practices and When to Use It.

Pagination, Errors and N+1 Problem - GraphQL | CodeYourCraft | CodeYourCraft