Implement Cursor Based Pagination GraphQL

Master the industry-standard approach to API pagination

Introduction

Cursor-based pagination represents the gold standard for GraphQL APIs, offering a more reliable and performant approach compared to traditional offset-based pagination. Unlike offset pagination, which uses LIMIT and OFFSET clauses to skip through results, cursor-based pagination uses an opaque identifier that points to a specific position in the dataset. This approach provides significant advantages when working with dynamic datasets where records may be added, removed, or reordered between requests.

When you implement cursor-based pagination following the Relay Cursor Connections specification, you provide clients with a consistent interface that handles real-time data changes gracefully. The client receives a cursor string with each item, which they can then pass back to request the next set of items. This eliminates the common problems with offset pagination, such as skipped or duplicate items when the underlying data changes during pagination sessions.

Modern web applications, particularly those built with Next.js, benefit tremendously from properly implemented cursor-based pagination. Social media feeds, activity logs, notification systems, and any data that updates in real-time rely on cursor-based approaches to provide seamless infinite scrolling experiences. The pattern also integrates naturally with optimistic UI updates and caching strategies, making it essential for building responsive, performant applications.

Cursor-based pagination in GraphQL follows a standardized approach documented in the official GraphQL pagination guide, ensuring consistency across different API implementations and client libraries.

What You'll Learn

The Connection Pattern

Understand edges, nodes, and pageInfo components

Schema Definition

Define connection types in your GraphQL schema

Cursor Encoding

Strategies for generating and encoding cursors

Resolver Implementation

Write efficient paginated resolvers

Performance Optimization

Leverage keyset pagination for scalable APIs

Why Offset-Based Pagination Falls Short

Offset-based pagination, the traditional approach using LIMIT and OFFSET SQL clauses, seems intuitive at first glance. You request items 0-20, then items 20-40, and so on. However, this approach breaks down in several common scenarios that modern applications frequently encounter.

The most significant problem occurs when data changes between requests. Consider a social media feed where new posts arrive every few seconds. A user viewing page 3 (items 40-60) when a new post is submitted at position 5 will experience unexpected behavior. When they navigate to page 4, the items shift, causing confusion and potentially showing duplicate content or skipping posts entirely. This creates a frustrating user experience that cursor-based pagination elegantly solves.

Performance issues compound as datasets grow large. When paginating to page 1000 with 20 items per page, the database must fetch 20,020 records and discard 20,000 of them just to return the requested slice. This becomes increasingly expensive with scale, causing slower response times and higher database load. Cursor-based pagination, by contrast, can leverage indexed columns to jump directly to the starting position, maintaining consistent performance regardless of how far into the dataset you paginate.

Offset-based pagination also creates problems with concurrent modifications. If a user rapidly clicks through pages while simultaneously creating or deleting content, the offset calculation becomes unreliable. The client has no stable reference point to resume pagination, leading to inconsistent results across requests.

As noted in Contentful's comparison of pagination approaches, cursor-based pagination provides a more stable experience for real-time data that changes frequently.

The Cursor-Based Solution

Cursor-based pagination addresses these challenges by providing clients with an opaque reference point that identifies a specific item rather than a numerical position. When a client requests the next page of results, they provide the cursor from the last item they received, and the server returns items that come after that point. This approach remains stable even when data changes because the cursor references actual data rather than a calculated position.

The cursor itself typically encodes information needed to resume pagination, such as an indexed column value or a unique identifier. Base64 encoding prevents clients from attempting to interpret or modify cursor values, ensuring the server maintains full control over pagination logic. When the server receives a cursor, it can efficiently locate the starting point for the next page using indexed columns.

This pattern naturally supports real-time data updates. If new items appear before the current pagination position, they won't disrupt ongoing pagination sessions. If items are deleted, the next cursor simply skips past the gap, preventing the client from requesting non-existent records. The client experience remains smooth and predictable regardless of background data changes.

The Connection Pattern

The Relay Cursor Connections specification defines a structured pattern for paginated fields that separates the actual data from pagination metadata. This pattern consists of three key elements: connections, edges, and nodes. Understanding each component helps you implement pagination that works consistently across different GraphQL clients and use cases.

Connection

A connection represents the paginated relationship between two types. It contains the edges collection and pagination metadata through the pageInfo field. Think of it as the container that holds both the data your client requested and the information needed to continue pagination.

Edge

An edge represents a single item in the paginated list, wrapping the actual data (the node) with cursor information. The edge pattern exists because cursor values belong to the relationship between items, not to the items themselves. This separation allows the connection to include edge-specific metadata in the future without modifying the node type.

Node

The node is the actual object you want to retrieve--typically a user, post, comment, or other entity in your schema. Clients access nodes through the edges, which provide both the node data and its cursor position.

PageInfo

The pageInfo object provides boolean flags and cursor values that help clients navigate pagination without inspecting every edge. The hasNextPage and hasPreviousPage fields tell clients whether additional pages exist in either direction, while startCursor and endCursor provide convenient references for the current page boundaries.

This standardized structure, as documented in the GraphQL.org pagination documentation, provides a consistent interface that frontend developers can rely on when building pagination components.

Schema Definition

Following the official GraphQL pagination guidelines, your schema must define the connection types that structure paginated responses. The first and after arguments enable forward pagination, requesting a specified number of items after a given cursor. The last and before arguments support backward pagination for clients that need bidirectional navigation.

Notice that cursors use String types rather than IDs or integers. This maintains the opaque nature of cursors, ensuring clients treat them as opaque tokens rather than attempting to parse or generate them. The server can change cursor encoding strategies without breaking clients, as long as the external string format remains consistent.

schema.graphql
1type PageInfo {2 hasNextPage: Boolean!3 hasPreviousPage: Boolean!4 startCursor: String5 endCursor: String6}7 8type UserEdge {9 node: User!10 cursor: String!11}12 13type UserConnection {14 edges: [UserEdge!]!15 pageInfo: PageInfo!16 nodes: [User!]17}18 19type Query {20 users(21 first: Int22 after: String23 last: Int24 before: String25 ): UserConnection26}

Cursor Encoding Strategies

The most common cursor encoding approach uses Base64 encoding to create opaque, URL-safe strings. This encoding serves multiple purposes: it prevents clients from attempting to interpret cursor values directly, it works reliably across different encoding environments, and it produces compact strings suitable for URLs and API responses.

A typical cursor encodes an indexed value along with any additional information needed to resume pagination. For ordered collections, encoding a single column value often suffices. For more complex scenarios with composite keys or multiple column ordering, you might encode a more complex structure containing multiple values.

The prefix in the cursor content prevents encoding collisions and helps with debugging. If a client accidentally passes a non-cursor string, the decode function gracefully handles the error instead of producing garbage data.

For composite keys or multiple column ordering, you might encode a more complex structure that includes both the sort value and a unique identifier. This ensures deterministic pagination when duplicate values exist in the primary sort column.

For many applications, you can use existing database IDs as cursors directly, simply encoding them with base64. This approach works well when queries are ordered by a unique, indexed column like the primary key or a creation timestamp combined with an ID. This strategy simplifies implementation because you're already indexing these columns for primary key lookups.

cursor-encoding.ts
1function encodeCursor(value: number | string): string {2 const cursor = Buffer.from(`cursor:${value}`).toString('base64')3 return cursor4}5 6function decodeCursor(cursor: string): number | string | null {7 try {8 const decoded = Buffer.from(cursor, 'base64').toString('utf-8')9 const match = decoded.match(/^cursor:(\d+)$/)10 return match ? parseInt(match[1], 10) : null11 } catch {12 return null13 }14}15 16// Composite cursor for multi-column ordering17function encodeCompositeCursor(values: [string, number]): string {18 const cursor = JSON.stringify({19 k: values[0],20 v: values[1]21 })22 return Buffer.from(cursor).toString('base64')23}

Writing the Paginated Resolver

The resolver for a paginated field must handle the cursor arguments, decode the cursor, fetch the appropriate slice of data, generate cursors for returned items, and construct the connection response. The key insight is fetching limit + 1 items to determine whether another page exists without needing a separate count query.

Without this approach, you'd need a separate count query to determine total pages, which is expensive and often unnecessary. The resolver also builds pageInfo with the appropriate boolean flags and cursor values for both ends of the current page.

When your API supports multiple sort orders, the cursor encoding must include the sorted columns. This ensures pagination works correctly regardless of how users sort their results, requiring a composite cursor that encodes both the sort value and a unique identifier.

resolver.ts
1const usersResolver = async (_: any, args: { first?: number; after?: string }) => {2 const limit = args.first ?? 203 let offset = 04 5 if (args.after) {6 const cursorIndex = decodeCursor(args.after)7 if (cursorIndex !== null) {8 offset = cursorIndex + 19 }10 }11 12 const users = await db.users.findMany({13 take: limit + 1,14 skip: offset,15 orderBy: { createdAt: 'asc' }16 })17 18 const hasNextPage = users.length > limit19 const slicedUsers = hasNextPage ? users.slice(0, limit) : users20 21 const edges = slicedUsers.map((user, index) => ({22 node: user,23 cursor: encodeCursor(offset + index)24 }))25 26 const pageInfo = {27 hasNextPage,28 hasPreviousPage: offset > 0,29 startCursor: edges.length > 0 ? edges[0].cursor : null,30 endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null31 }32 33 return { edges, pageInfo }34}

Database Integration Patterns

For PostgreSQL and other relational databases, cursor-based pagination typically translates to WHERE clauses that leverage indexed columns. The performance advantage over OFFSET comes from the database's ability to use B-tree indexes to jump directly to the starting position rather than scanning and discarding rows.

The composite key approach handles cases where the primary sort column has duplicate values. If multiple users share the same creation timestamp, the secondary ID column ensures a deterministic ordering that paginates correctly across all scenarios.

Document databases like MongoDB have similar patterns using query operators. The key consideration across all database types is ensuring your sort columns have appropriate indexes. Without indexes, cursor pagination loses its performance advantage because the database must scan from the beginning to find the cursor position.

pagination.sql
1-- PostgreSQL cursor-based pagination2SELECT * FROM users3WHERE created_at > '2024-01-15T10:30:00Z'4ORDER BY created_at ASC5LIMIT 21;6 7-- Composite key for duplicate values8SELECT * FROM users9WHERE (created_at, id) > ('2024-01-15T10:30:00', 'abc123')10ORDER BY created_at ASC, id ASC11LIMIT 21;12 13-- MongoDB/NoSQL equivalent14db.users.find({15 createdAt: { $gt: cursorTimestamp }16}).sort({ createdAt: 1 }).limit(21)

Performance Optimization

Keyset Pagination vs Offset

For very large datasets, keyset pagination (also called seek method) maintains consistent performance regardless of pagination depth:

  • Offset-based: Gets slower with larger offsets as database scans more rows
  • Keyset pagination: Constant time using indexed WHERE conditions

The trade-off is that keyset pagination doesn't support random access--you can only move forward or backward sequentially. Most UI patterns (infinite scroll, "load more" buttons) don't require random access, making keyset pagination ideal for high-scale applications.

Key Considerations

  • Ensure sort columns have appropriate indexes for efficient cursor positioning
  • Consider caching strategies for frequently accessed first pages
  • Use connection pooling for concurrent pagination requests
  • Clamp first/last arguments to reasonable limits (20-30 items per page)

As demonstrated in the GraphQL.js implementation guide, proper indexing and query optimization are critical for maintaining responsive pagination as your dataset grows.

Apollo Client Integration

When using Apollo Client with cursor-based pagination, the useQuery hook works naturally with connection patterns. The fetchMore function handles cursor-based pagination by passing the endCursor from the previous result as the after argument for the next request.

The updateQuery callback merges new edges with existing results, maintaining a seamless infinite scroll experience where new items append to the existing list without duplicating content. The cache automatically handles the merge based on the __typename and edge structure, ensuring consistent state management across the application.

Building React applications with Apollo Client and cursor-based pagination requires attention to edge cases: invalid cursors should return an appropriate error or default to first page, empty cursors should be treated as requesting from the beginning, extreme values should be clamped to reasonable limits, and missing data due to deleted items should be handled gracefully.

apollo-integration.tsx
1const FEED_QUERY = gql`2 query Feed($first: Int, $after: String) {3 feed(first: $first, after: $after) {4 edges {5 node {6 id7 title8 content9 }10 cursor11 }12 pageInfo {13 hasNextPage14 endCursor15 }16 }17 }18`19 20function Feed() {21 const { data, fetchMore } = useQuery(FEED_QUERY, {22 variables: { first: 20 }23 })24 25 const loadMore = () => {26 fetchMore({27 variables: {28 after: data.feed.pageInfo.endCursor29 },30 updateQuery: (previousResult, { fetchMoreResult }) => {31 if (!fetchMoreResult) return previousResult32 return {33 feed: {34 __typename: 'FeedConnection',35 edges: [...previousResult.feed.edges, ...fetchMoreResult.feed.edges],36 pageInfo: fetchMoreResult.feed.pageInfo37 }38 }39 }40 })41 }42 43 return (44 <div>45 {data?.feed.edges.map(({ node, cursor }) => (46 <FeedItem key={cursor} node={node} />47 ))}48 {data?.feed.pageInfo.hasNextPage && (49 <button onClick={loadMore}>Load More</button>50 )}51 </div>52 )53}
Best Practices Summary

Appropriate Cursor Encoding

Simple IDs work for many cases, composite cursors handle multi-column ordering

Indexed Columns

Leverage indexed columns for cursor positioning to avoid performance pitfalls at scale

Sensible Default Limits

Use 20-30 items per page to balance server load with client performance

Thorough Testing

Test with realistic scenarios: items added/deleted during pagination, invalid cursors, concurrent modifications

Common Questions

Build Scalable GraphQL APIs with Digital Thrive

Our team specializes in implementing robust API architectures including cursor-based pagination, schema design, and performance optimization. Contact us to discuss how we can help build reliable, performant APIs for your application.

Sources

  1. GraphQL Cursor Connections Specification (Relay) - The formal specification for cursor-based pagination in GraphQL
  2. GraphQL.org Pagination Documentation - Official best practices documentation
  3. GraphQL.js Cursor-based Pagination - Implementation guide with code examples
  4. Contentful GraphQL Pagination Tutorial - Comprehensive comparison of cursor vs offset pagination
  5. Apollo Client Cursor-based Pagination - Practical implementation patterns for React applications