Implementing Cursor-Based Pagination in Technical Detail
AI generated
{ }
GET
REST · Pagination · Performance
Cursor-Based Pagination
Implementing it in technical detail: from the offset problem to a stable cursor token

Offset pagination with OFFSET and LIMIT looks simple at first glance, but returns shifted, duplicate, or skipped entries once the underlying data changes between two requests. Cursor-based pagination (also called keyset pagination) fixes this structurally by anchoring every page to an actual record instead of a numeric position, through an opaque token built from a sort key and a unique id.

15 min read Cursor · Keyset Pagination Symfony · Doctrine

1. Why offset pagination becomes unreliable on changing data

Pagination on most REST APIs starts out deceptively simple: a client asks for a slice of a list with ?page=3&limit=20 or ?offset=40&limit=20, and the database returns exactly that slice via OFFSET and LIMIT. As long as the underlying dataset stays put between two requests, this pattern works reliably and takes only a few lines of SQL. The trouble starts as soon as rows get inserted or deleted while a client is still working through the result list, because every subsequent page shifts relative to the original request.

A concrete example makes the problem tangible: a list of orders sorted descending by created_at, a user loads page 1 with the twenty newest entries. If a new order arrives between loading page 1 and page 2, the entire dataset shifts by one position, and OFFSET 20 now returns an item that was already visible on page 1, a duplicate in the UI. If a row is deleted instead, an item can be skipped entirely without the client ever noticing. For lists with high write frequency, such as activity feeds or order lists, this is not an edge case, it is the norm.

2. How cursor pagination solves the problem structurally

Cursor pagination drops the idea of a numeric position (the nth row) and instead anchors to an actual record as the starting point: give me the next twenty entries after this one. That anchor stays stable even if rows change before it, because it does not refer to a position in the result set but to a concrete value in the sort key. This is why the technique is also called keyset pagination, the key rather than the position defines the starting point for the next page.

In practice, this anchor is handed back to the client as an opaque cursor token, a string the client stores and resends unchanged on the next request without knowing or interpreting its contents. Behind the scenes, that token is usually a combination of the sort key value (for example created_at) and a unique id, encoded as JSON and then base64 encoded so it can travel as a single, URL-safe string. This opacity is intentional: the client should never construct or manipulate the token itself, only pass it along as a black box.

3. Implementing it with Symfony and Doctrine: the core query

The conceptual SQL condition for keyset pagination is WHERE (sort_key, id) > (cursor_key, cursor_id), a row value comparison that MySQL 8 and PostgreSQL support natively. Since Doctrine's QueryBuilder does not map that comparison directly, the condition is expressed in practice as a logical disjunction: either the sort key is strictly greater than the cursor value, or it is equal and the id is greater. Both formulations return the same result, but the OR version is more portable and works in any Doctrine version without extra setup.

It matters that both the sort key and the id are part of the same composite index, otherwise the database still has to scan a large part of the table despite the lean WHERE clause. For the first page there is no cursor yet, in that case the WHERE condition is simply omitted and the query returns the first n rows in sort order. The repository below shows the full implementation, including encoding and decoding of the cursor token.


<?php
declare(strict_types=1);

final class ProductCursorRepository
{
    public function __construct(private readonly Connection $connection)
    {
    }

    /**
     * Loads the next page via keyset pagination.
     * Sort key: created_at, tie breaker: id (both indexed).
     */
    public function findNextPage(?string $cursor, int $limit): array
    {
        $qb = $this->connection->createQueryBuilder()
            ->select('id', 'name', 'price', 'created_at')
            ->from('product')
            ->orderBy('created_at', 'ASC')
            ->addOrderBy('id', 'ASC')
            ->setMaxResults($limit + 1);

        if ($cursor !== null) {
            [$cursorCreatedAt, $cursorId] = $this->decodeCursor($cursor);
            $qb->andWhere('(created_at > :createdAt) OR (created_at = :createdAt AND id > :id)')
                ->setParameter('createdAt', $cursorCreatedAt)
                ->setParameter('id', $cursorId);
        }

        return $qb->executeQuery()->fetchAllAssociative();
    }

    private function decodeCursor(string $cursor): array
    {
        $decoded = json_decode(base64_decode($cursor, true), true, flags: JSON_THROW_ON_ERROR);

        if (!isset($decoded['created_at'], $decoded['id'])) {
            throw new InvalidArgumentException('Cursor token is malformed or incomplete.');
        }

        return [$decoded['created_at'], (int) $decoded['id']];
    }

    public function encodeCursor(string $createdAt, int $id): string
    {
        return base64_encode(json_encode(['created_at' => $createdAt, 'id' => $id], JSON_THROW_ON_ERROR));
    }
}

4. How the cursor token is built and encoded

A cursor token essentially contains exactly the values needed for the next page's WHERE clause: the sort key value and the id of the last item on the current page. These values are serialized as an associative array into JSON and then base64 encoded, so that arbitrary date formats, special characters, or numbers travel safely inside a single URL parameter without extra escaping.

When decoding on the server, an incoming token should never be trusted blindly: a tampered or simply malformed base64 fragment must result in a clean 400 response instead of an unhandled exception. Type checking the decoded values matters just as much, since a client could in theory send a syntactically valid but semantically wrong token, for example an id encoded as a string instead of a number. A robust implementation therefore explicitly validates that the expected fields are present and of the expected type before they flow into the query.

5. Why cursor pagination performs noticeably better on large tables

With OFFSET n, the database first has to read (or at least walk through the index positions of) n rows and discard them before returning the rows actually requested. That cost grows linearly with page depth: page 1000 with OFFSET 20000 is noticeably slower than page 1, even though both requests return the same number of rows. On tables with several million rows this effect quickly becomes a visible performance problem, especially for infinite scroll interfaces that page deep into a list.

The WHERE clause used by cursor pagination, by contrast, translates into a classic index seek: the database jumps directly to the right position in the composite index and reads the next n rows from there, regardless of how deep that position sits in the overall list. Response time therefore stays roughly constant across pages, a decisive advantage for APIs with large, growing datasets where users routinely page or scroll deep into a list.

6. Why the id as a tie-breaker is not optional

A plain sort key like created_at is rarely truly unique in practice: if several rows are created within the same second or millisecond, for example through a batch import, they share the exact same timestamp. Without a second, guaranteed unique comparison value, the database cannot reliably tell which rows were already delivered and which still remain when sort keys tie, leading right back to the skipped or duplicated entries that cursor pagination was supposed to fix in the first place.

The fix is combining the sort key with a unique id as a tie-breaker, both in the ordering and in the WHERE clause. That produces a genuine total order over the result set, every row has a unique position relative to every other row. This requires a composite index on exactly these two columns in exactly this order, without that index the performance advantage over offset pagination mostly evaporates.

7. Paging forward and backward: bidirectional cursors

A complete pagination API usually needs more than a next_cursor, it also needs a way to page back to the previous page. In practice that means every response carries both a cursor for the next page and one for the previous page, together with hasNextPage and hasPreviousPage flags. To determine hasNextPage reliably without an extra COUNT query, implementations commonly fetch one row more than they display (limit plus one), and its presence signals that another page exists.

Paging backward requires flipping both the comparison direction in the WHERE clause and the sort direction, so the database effectively walks backward through the index. The resulting rows come back in reverse order though, and need to be flipped once more before returning them to the client, so the display order stays consistent across both paging directions. This logic is worth encapsulating centrally once, rather than reimplementing it in every endpoint.

8. Limits and downsides of cursor pagination

The most obvious downside is losing the ability to jump directly to an arbitrary page: with offset pagination, jump straight to page 47 is trivially OFFSET 940, with cursor pagination that is not structurally possible, since every cursor only knows the immediately next position. For UIs with classic page-number navigation (1, 2, 3, ... 47), cursor pagination is a poor fit or requires a compromise, such as a hybrid approach for the first few pages only.

Cursor pagination also does not solve the problem of showing an exact total result count, since a full COUNT query over a very large table stays expensive regardless of the pagination technique used. If an exact total is genuinely required, it needs a separate strategy, such as a cached, periodically refreshed counter or a deliberately approximate estimate, instead of counting the entire table live on every request.

9. When cursor pagination beats offset pagination, and vice versa

The choice between the two techniques comes down to a handful of criteria: how volatile the underlying data is, how large the table is, and whether the UI needs classic page-number navigation or rather infinite scroll or feed-style loading. The table below summarizes the key points for a quick decision on a concrete project.

Criterion Offset Pagination Cursor Pagination Recommendation
Consistency under changing data Prone to duplicates/gaps Stable, no shifting Cursor for volatile lists
Jumping to an arbitrary page Possible (OFFSET n) Not possible Offset for page-number navigation
Performance on large tables Degrades with growing offset Constant via index seek Cursor above a high row count
Implementation effort Low Higher (encoding, tie-breaker) Offset for small, static lists
Fit for infinite scroll Suboptimal Ideal Cursor for feed/scroll UIs

Mironsoft

OpenAPI design, Symfony APIs, and API security

APIs that external teams can integrate without back-and-forth questions?

We review existing REST APIs for inconsistent error formats, missing OpenAPI documentation, and security gaps, then build an API that is clearly documented, versioned, and hardened against abuse.

API Review

Checking the OpenAPI spec, error formats, and status codes for consistency.

Symfony Implementation

Using DTOs, Serializer, and Validator for clean, type-safe request/response models.

Security Audit

Hardening rate limiting, auth schemes, and input validation against real attack surfaces.

10. Summary

Cursor-Based Pagination: The Essentials at a Glance

Core problem

Offset pagination returns shifted, duplicated, or skipped entries under concurrent writes, because it anchors to a numeric position instead of an actual record.

The fix

An opaque cursor token built from a sort key and a unique id anchors to an actual record as a stable starting point for the next page.

Performance

The WHERE clause resolves to an index seek instead of a growing OFFSET scan, keeping response time roughly constant across all pages.

Practical advice

Use cursor pagination for volatile, large lists and infinite scroll, keep offset pagination only for small, stable lists with page-number navigation.

11. FAQ: Cursor-Based Pagination: The Essentials at a Glance

1Can I combine cursor and offset pagination in the same API?
Yes, that is a common pattern. Endpoints with high write frequency or very large tables get cursor pagination, smaller, stable lists stay on simple offset pagination.
2Does the sort key always have to be a timestamp?
No, any sortable, indexed value works as a sort key, such as a price or a name. What matters is that it is indexed together with the unique id.
3What happens if a cursor token is tampered with?
The server must validate the token on decode and respond with a 400 error for malformed or implausible values, instead of feeding them into the query unchecked.
4Why is a single sort key often not enough?
Because multiple rows can share the same sort key value, for example an identical timestamp. Without a unique id as a tie-breaker this leads to skipped or duplicated entries.
5How do I still show a total result count?
Through a separate, ideally cached counting strategy, since an exact COUNT query over a very large table stays expensive regardless of the pagination technique.
6Does cursor pagination work with multiple sort criteria?
Yes, the WHERE clause then grows with additional comparison levels, and the composite index needs to cover all involved columns in the correct order.
7Is the cursor token readable by the client?
Technically yes, since base64 is an encoding, not encryption. The client should still treat the token strictly as an opaque black box and never construct it manually.
8What happens if a cursor points to a deleted row?
That is not a problem, since the WHERE clause only relies on the sort key and id values, not on the existence of the referenced row itself.
9Do I always need a composite index for cursor pagination?
Yes, without an index covering both the sort key and the id together, the performance advantage over offset pagination mostly disappears, since the database still has to scan large parts of the table.
10Does cursor pagination fit GraphQL APIs?
Yes, the Relay connection specification pattern in GraphQL is built on essentially the same cursor principle with edges, node, and pageInfo.