Implementing GraphQL Pagination: Cursor-Based vs Offset-Based

Implementing GraphQL Pagination: Cursor-Based vs Offset-Based

Development and maintenance of all types of websites:

Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:

Frequently Asked Questions

Latest works

  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1281
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1237
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    977
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    1027
  • image_website-sbh_0.webp
    Website development for SBH Partners
    1103
  • image_website-_0.webp
    Website development for Red Pear
    550

Implementing GraphQL Pagination: Cursor-Based vs Offset-Based

Imagine your online store displays a catalog of products paginated, 20 per page. A user navigates to page 3, and at that moment an admin adds a new product. What happens? Offset pagination shifts all rows — page 3 now shows duplicates from page 2. Sounds familiar? Cursor-based pagination solves this completely: the cursor fixes the position, and no inserts disrupt the selection.

In practice, we use both strategies depending on the task. In this article, we'll dive into the technical implementation of cursor-based (Relay-style) and offset-based pagination, compare their performance, and provide ready code examples.

Problems We Solve

Duplicates with offset. If a user goes to page 3 and a new record is inserted between requests, it shifts all rows — the user sees duplicates. Slow performance with large offset. LIMIT 20 OFFSET 1000000 forces the database to scan a million rows before returning 20 — the time difference can be 100× compared to a cursor query. For example, on a test table with 1 million records, offset with a skip of 500,000 took 2.3 seconds, while a cursor query took 0.02 seconds. Difficulty with arbitrary jumps in cursor. Cursor-based does not support jumping to an arbitrary page — only forward/backward. We address all these scenarios by selecting the optimal strategy for your stack.

Offset Pagination

Suitable for admin tables and lists with rare updates:

type Query { posts(limit: Int = 20, offset: Int = 0): PostList! } type PostList { items: [Post!]! total: Int! limit: Int! offset: Int! hasNextPage: Boolean! } 
const resolvers = { Query: { posts: async (parent, { limit = 20, offset = 0 }, context) => { const safeLimit = Math.min(limit, 100) const [items, total] = await Promise.all([ context.db.query( 'SELECT * FROM posts ORDER BY created_at DESC LIMIT $1 OFFSET $2', [safeLimit, offset] ), context.db.queryOne('SELECT COUNT(*) as total FROM posts') ]) return { items, total: parseInt(total.total), limit: safeLimit, offset, hasNextPage: offset + safeLimit < parseInt(total.total) } } } } 

Cursor-Based Pagination (Relay Connection)

The Relay standard (see Relay GraphQL Server Specification) is the right choice for infinite scroll and frequently changing data:

type Query { posts( first: Int after: String last: Int before: String filter: PostFilter ): PostConnection! } type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! totalCount: Int! } type PostEdge { node: Post! cursor: String! } type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String } 
// Cursor is base64-encoded ID or timestamp function encodeCursor(id) { return Buffer.from(`cursor:${id}`).toString('base64') } function decodeCursor(cursor) { const decoded = Buffer.from(cursor, 'base64').toString('utf8') const match = decoded.match(/^cursor:(.+)$/) return match ? match[1] : null } const resolvers = { Query: { posts: async (parent, { first = 20, after, last, before, filter }, context) => { const limit = Math.min(first || last || 20, 100) let query = 'SELECT * FROM posts' const params = [] const conditions = [] if (filter?.authorId) { params.push(filter.authorId) conditions.push(`author_id = $${params.length}`) } if (after) { const afterId = decodeCursor(after) params.push(afterId) conditions.push(`id < $${params.length}`) // for DESC sort } if (before) { const beforeId = decodeCursor(before) params.push(beforeId) conditions.push(`id > $${params.length}`) } if (conditions.length) { query += ' WHERE ' + conditions.join(' AND ') } query += ' ORDER BY id DESC' params.push(limit + 1) query += ` LIMIT $${params.length}` const rows = await context.db.query(query, params) const hasMore = rows.length > limit const items = hasMore ? rows.slice(0, limit) : rows const edges = items.map(row => ({ node: row, cursor: encodeCursor(row.id) })) const totalCount = await context.db.queryOne( 'SELECT COUNT(*) FROM posts' ).then(r => parseInt(r.count)) return { edges, totalCount, pageInfo: { hasNextPage: after ? hasMore : false, hasPreviousPage: before ? hasMore : false, startCursor: edges[0]?.cursor ?? null, endCursor: edges[edges.length - 1]?.cursor ?? null } } } } } 

Why Cursor-Based Pagination Is Faster at Large Offsets?

An offset query with OFFSET 1000000 forces the database to read a million rows before returning 20. Cursor-based uses an index seek: WHERE id > last_id — this is O(log N). On a table with 10 million rows, the time difference reaches 100×. Cursor-based saves CPU and I/O resources, which is critical under high load.

How to Choose Between Offset and Cursor Pagination?

The choice depends on the scenario. For example, for a social media feed we use only cursor-based — the user won't notice duplicates when new posts are added. For an admin panel with search by ID, offset is fine because you need page 5 out of 100.

Scenario Recommendation
Admin panel with page navigation Offset
Infinite scroll feed Cursor
Real-time updates (chat, notifications) Cursor
Export all data (no pagination) Offset with limit
Search with filters Depends on update frequency

How to Implement Pagination with Relay Connection?

Step by step:

  1. Define the Connection type (edges, pageInfo) in the schema.
  2. In the resolver, fetch one extra record beyond the limit to determine hasNextPage.
  3. Encode the cursor in base64 (can use ID or composite key).
  4. On the client, set up fetchMore and field policy for merging.
// Apollo Client — infinite scroll const { data, fetchMore, loading } = useQuery(GET_POSTS, { variables: { first: 20 } }) const loadMore = () => { const endCursor = data.posts.pageInfo.endCursor if (!endCursor || !data.posts.pageInfo.hasNextPage) return fetchMore({ variables: { first: 20, after: endCursor }, updateQuery: (prev, { fetchMoreResult }) => { if (!fetchMoreResult) return prev return { posts: { ...fetchMoreResult.posts, edges: [ ...prev.posts.edges, ...fetchMoreResult.posts.edges ] } } } }) } // With Apollo Client 3 — InMemoryCache field policies const cache = new InMemoryCache({ typePolicies: { Query: { fields: { posts: relayStylePagination(['filter']) } } } }) 

Comparison: Offset vs Cursor

Criteria Offset Cursor
Arbitrary page jump Yes No
Correctness on insert No (duplicates/skips) Yes
Sort by any field Easy Requires index
Infinite scroll No Yes
Scalability (OFFSET 1M) Slow Fast (up to 100×)
Implementation Simpler More complex

What's Included in Turnkey Pagination Implementation

Our team provides:

  • analysis of your current data schema and strategy selection;
  • design of Connection types and resolvers;
  • implementation of offset and cursor pagination per Relay standard;
  • client-side setup (Apollo Client, field policies);
  • load testing (up to 10,000 records, up to 1,000 concurrent requests);
  • API documentation in GraphQL Playground;
  • delivery of source code and access.

Contact us for a consultation — we'll find the optimal solution for your task. Order pagination implementation, and we guarantee correct operation under any data update scenarios. Our experience includes over 30 projects with GraphQL pagination.

Timeline

Implementation of pagination (offset + cursor Relay Connection) for GraphQL API: 1–2 business days. The cost is calculated individually based on schema complexity. We'll assess your project for free.