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.
The simplest approach is offset pagination, familiar from SQL:
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:
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.
GraphQL responses can contain both data and errors at the same time, because one field can fail while others succeed:
{
'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:
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.
Consider this query:
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.
DataLoader batches all the individual lookups that happen in one tick of the event loop into a single query:
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.
errors array; add extensions.code for machine-readable errors.In the final lesson we zoom out and cover GraphQL Best Practices and When to Use It.