Cursor Pagination in Mobile Development — What It Is, Principle, and Implementation

Author: IT Sectr Published: 2026-03-11 Reading time: 9 min

Cursor Pagination is a method of paginated data loading that uses a unique cursor to navigate through an ordered set of records. According to the GraphQL Specification (2025), cursor-based pagination is the recommended standard for APIs working with dynamic data. Cursor pagination eliminates the main drawbacks of the Offset approach: instability during insertions and performance degradation on large offsets.

Key Takeaways

  • Cursor Pagination is a pagination method where each record has a unique cursor identifier for navigation.
  • Cursor is a unique position marker in a data set (usually ID, UUID, timestamp) that does not change on insertions.
  • Stability — new records added between requests do not shift the cursor, eliminating duplicates and gaps.
  • Performance — the WHERE id > cursor query efficiently uses the index without losing speed on large datasets.
  • Limitation — cursor pagination does not support navigation by page number (you cannot jump to page 5).

What is Cursor Pagination?

Cursor Pagination is a paginated loading method where the server returns a special pointer — a cursor — along with the data. The client uses this cursor in the next request to retrieve the next batch of records. The cursor is a unique identifier of the last element on the current page.

Unlike Offset pagination, where the client says “give me page 5 with 20 records,” cursor pagination works differently: “give me 20 records after the record with ID = 83.” The server executes a query with WHERE id > 83 and LIMIT 20. This approach guarantees that each record falls into exactly one page regardless of insertions.

The concept of cursor pagination gained widespread adoption thanks to the Relay Connection (GraphQL) specification, which made cursor-based pagination the standard for modern APIs. Relay defines the response format: edges (array of records with cursors), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).

History

Cursor pagination is not a new technique — it was used in databases long before the web. In SQL it’s called keyset pagination or the seek method. The method became popular in APIs after the publication of the Relay specification in 2015, which formalized the cursor format as a base64-encoded string for uniformity over HTTP transport.

How Cursor Pagination Works

The basic principle of cursor pagination is that the query uses a WHERE condition on an indexed field for positioning, not an offset. For forward direction, WHERE id > last_id is used; for backward direction, WHERE id < first_id is used. The B-tree index finds the first record after the cursor in O(log n), providing stable response time.

sql
-- Get 20 records after the '83' cursor
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;

-- Get 20 records BEFORE the '83' cursor (backward)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;

Cursor Format

A cursor can be simple (an ID value) or complex (composite from multiple fields). Simple cursors are the primary key of a record, for example, auto-increment id or UUID. Composite cursors are used for sorting by non-unique fields, for example (created_at, id), where id guarantees uniqueness when timestamps are identical.

A typical API format is a cursor as a base64-encoded string. The server decodes the cursor, extracts the value, and builds the SQL query. Base64 encoding hides the internal cursor structure from the client and allows changing the format without breaking backward compatibility. The client receives cursors in the endCursor field of the response and passes them as a string in the next request.

Forward and Backward Navigation

Cursor pagination supports bidirectional navigation. For forward movement (next), the cursor of the last element on the current page is used; for backward (previous), the cursor of the first element. The after and before parameters in the request determine the direction: after takes records after the cursor, before takes records before the cursor.

Cursor vs Offset Pagination

The choice between cursor and Offset pagination is one of the key architectural decisions when designing an API. Each method has strengths and weaknesses that determine its applicability. Cursor pagination wins in scenarios with dynamic data; Offset wins in scenarios with arbitrary navigation.

CharacteristicCursorOffset
Stability on insertionsHigh (no duplicates)Low (page shifting)
Performance on large datasetsO(log n) — stableO(n) — degrades with growth
Navigation by page numberNoYes (page=5)
Implementation complexityMediumLow
REST supportcursor/before/afterpage/offset
GraphQL supportRelay standardNot recommended

Why Offset Fails at Scale

Offset pagination performs a full table scan up to the OFFSET position. At offset=100000, the database reads and skips 100000 rows, even if LIMIT is 20. MySQL and PostgreSQL cannot optimize OFFSET — this is a LIMIT/OFFSET implementation feature in SQL. Cursor pagination uses a B-tree index that finds the position in O(log n).

An additional Offset issue is “skipping” records when paginating backwards. If a user loaded page 5 and new records were added at that moment, when requesting page 6 they will either see the record from page 5 again or miss new ones. Cursor pagination completely eliminates this scenario: the cursor points to a specific place in the set, and insertions do not change the position.

Cursor Pagination Implementation

Let’s look at cursor pagination implementation on the backend (Kotlin + Spring) and on the client (Android + Retrofit). The server accepts after, before, limit parameters and returns a list of records with cursors and pageInfo. A typical response contains hasNextPage and hasPreviousPage for managing the pagination UI.

kotlin
@GetMapping("/posts")
fun getPosts(
    @RequestParam after: Long?,
    @RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
    val cursor = after ?: Long.MAX_VALUE
    val posts = repository.findByIdLessThanOrderByIdDesc(
        cursor, PageRequest.of(0, limit)
    )
    val endCursor = posts.lastOrNull()?.id
    return CursorResponse(
        data = posts,
        pageInfo = PageInfo(
            hasNextPage = posts.size == limit,
            endCursor = endCursor
        )
    )
}

Client Implementation on Android

On the client, cursor pagination is implemented via PagingSource from Paging 3, where the key is a cursor (Long). PagingSource.load receives LoadParams.key — the cursor of the last loaded record. LoadResult.Page returns the data and nextKey — the cursor for the next page. When nextKey = null, pagination is complete.

kotlin
// Retrofit API
interface PostApi {
    @GET("posts")
    suspend fun getPosts(
        @Query("after") after: Long?,
        @Query("limit") limit: Int = 20
    ): CursorResponse<Post>
}

// PagingSource with cursor key
class PostPagingSource(
    private val api: PostApi
) : PagingSource<Long, Post>() {

    override suspend fun load(
        params: LoadParams<Long>
    ): LoadResult<Long, Post> = try {
        val response = api.getPosts(
            after = params.key,
            limit = params.loadSize
        )
        val nextKey = response.pageInfo.endCursor
        LoadResult.Page(
            data = response.data,
            prevKey = null,
            nextKey = nextKey
        )
    } catch (e: Exception) {
        LoadResult.Error(e)
    }
}

GraphQL Implementation via Relay

In GraphQL, cursor pagination is implemented through the Relay Connection pattern. Each type has a Connection (with pageInfo and edges) and an Edge (node + cursor). The query passes first, after, last, before parameters. The server returns an array of edges with cursors and pageInfo with hasNextPage/hasPreviousPage.

When to Use Cursor Pagination

Cursor pagination is recommended for APIs working with dynamic data where records are frequently added or deleted. Classic examples: a news feed in a social network, chat messages, transaction history, post comments. In all these scenarios, consistency and absence of duplicates are important.

  • Chats and messengers — each new message is added to the top of the list. Offset pagination gets disrupted with each new message.
  • Social networks and feeds — posts are published continuously. Cursor pagination guarantees the user doesn’t miss a single post.
  • Order and transaction history — data changes less frequently, but consistency is critical for financial reporting.
  • APIs with large data volumes — millions of records. Cursor pagination maintains performance where Offset starts to slow down.
  • GraphQL APIs — the Relay standard requires cursor-based pagination for specification compliance.
  • Mobile apps with infinite scroll — the user scrolls down, loading new batches. The cursor approach provides a smooth UX without duplicates.

When Cursor Pagination Is Not Suitable

There are scenarios where Offset pagination is more convenient: admin panels where navigation by page number is needed; search with pagination where results may change; reports and analytics where a fixed link to page 5 is needed. In these cases, the advantages of the cursor do not outweigh the implementation complexity.

Cursor pagination does not support “jumping” to an arbitrary page — the user cannot click “Page 5” and go there. This is an architectural limitation: counting the total number of pages requires a separate count query, which can be expensive for large tables. In such cases, a hybrid approach: cursor for data + count for pagination.

Frequently Asked Questions

What is a cursor in Cursor Pagination?

A cursor is a unique record identifier that points to a position in the data set. It can be simple (a record ID) or composite (multiple fields). The client receives the cursor of the last record on the page and passes it in the next request to get the next batch.

Why is Cursor Pagination better than Offset?

Cursor pagination is not subject to shifting when new records are added — each element falls into exactly one page. It also maintains speed on large volumes by using indexes instead of scanning the first n rows. Offset is simpler but unstable for dynamic data.

Can cursor pagination be implemented without GraphQL?

Yes, cursor pagination is not tied to GraphQL. It can be implemented in any REST API by passing the cursor as a query parameter ?after=83&limit=20. The response should contain pageInfo with endCursor and hasNextPage — this allows the client to manage loading without knowing the internal cursor structure.

Which cursor to use — ID, UUID, or timestamp?

Auto-increment ID is the optimal choice: monotonically increasing, does not change, efficiently indexed. UUID v7 (time-ordered) also works. Timestamps can produce duplicates at the same time, so combine it with ID: (created_at, id) for guaranteed cursor uniqueness.

How to find the total number of pages with cursor pagination?

Cursor pagination does not provide the total number of pages — this is its limitation. If you need total information, execute a separate COUNT query with the same filters. For large tables, use approximate counting via EXPLAIN or a cached total from analytics.

Summary

  • Cursor Pagination is a pagination method with navigation by a unique record identifier instead of an offset.
  • The cursor guarantees set stability on insertions: new records do not shift already loaded pages.
  • Performance on large data volumes remains high (O(log n)) thanks to B-tree index usage.
  • Cursor pagination is suitable for dynamic data: chats, news feeds, transactions, comments.
  • The main limitation is the lack of navigation by page number and the inability to jump to an arbitrary page.
  • Implementation uses WHERE id after cursor, after/before parameters, and pageInfo in the response.
  • Standard — Relay Connection GraphQL, but REST APIs with cursor parameters are also widely used.

We will develop a mobile application turnkey

IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.

Discuss the project

Read also