Scaling GraphQL: DataLoaders, Cursors, and N+1 Prevention
GraphQL's flexibility is a double-edged sword: resolvers naturally compose into deeply nested queries, but naive implementations can trigger thousands of database queries for a single request. As Velocity grew, we invested heavily in three patterns to keep our GraphQL API fast and scalable: DataLoader batching, relay-style cursor pagination, and base64 composite cursors.
DataLoader is the cornerstone of N+1 prevention. Every resolver that fetches related data (e.g., Issue.assignee, Issue.project) delegates to a DataLoader that batches and caches requests within a single GraphQL operation. For example, our UserLoader collects all user IDs from child resolvers, issues a single SELECT * FROM users WHERE id = ANY($1) query, and distributes results back to the callers. This turns O(n) queries into O(1).
We built 20+ DataLoaders covering users, projects, teams, labels, statuses, and more. Each loader follows the same pattern: accept an array of keys, fetch all matching rows in one query, return a Map of key→value, and let DataLoader handle deduplication and caching. The loaders live in packages/graphql/src/loaders/ and are instantiated per-request in the GraphQL context.
For pagination, we adopted Relay's cursor-based approach. Each edge in a paginated list has a cursor (base64-encoded composite key like created_at|id) and the connection returns pageInfo with hasNextPage and endCursor. This allows efficient forward/backward traversal without offset skipping, which degrades performance on large datasets. Our encodeCursor and decodeCursor utilities handle the base64 encoding and composite key parsing.
The composite cursor pattern is critical for stable pagination. Using id alone as a cursor breaks when rows are inserted or deleted between pages. By combining sort_field|id (e.g., created_at|uuid), we get a stable, unique cursor that works even as data changes. PostgreSQL handles the composite WHERE clause efficiently: WHERE (created_at, id) > ($1, $2).
These patterns transformed our API performance. A query that previously triggered 500+ database calls now runs in 2-3 queries. Response times dropped from seconds to milliseconds, and we can comfortably handle 10k RPM on a modest Supabase instance. If you're building a production GraphQL API, invest in DataLoaders and cursor pagination early—your future self will thank you.