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 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).
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.
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.
-- 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;
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.
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.
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.
| Characteristic | Cursor | Offset |
|---|---|---|
| Stability on insertions | High (no duplicates) | Low (page shifting) |
| Performance on large datasets | O(log n) — stable | O(n) — degrades with growth |
| Navigation by page number | No | Yes (page=5) |
| Implementation complexity | Medium | Low |
| REST support | cursor/before/after | page/offset |
| GraphQL support | Relay standard | Not recommended |
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.
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.
@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
)
)
}
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.
// 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)
}
}
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.
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.
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
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.
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.
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.
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.
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
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.
Read also