cursor against offset and which strategy matters when
Pagination looks like a trivial detail at first, yet in API Platform it decides response times on large tables, consistency under concurrent writes, and ergonomics for client developers. Anyone who only knows the default offset pagination gives away performance and risks duplicate or missing records on heavily used endpoints.
Table of contents
- 1. Why pagination is more than a detail question
- 2. Offset pagination: the default case in API Platform
- 3. Where offset pagination hits its limits
- 4. Enabling cursor based pagination
- 5. Understanding keyset pagination with a proper index
- 6. Building a custom paginator for external sources
- 7. Partial pagination for very large collections
- 8. Client ergonomics: Hydra links and OpenAPI parameters
- 9. Pagination strategies compared
- 10. Summary
- 11. FAQ
1. Why pagination is more than a detail question
Every collection operation in API Platform returns a paginated response by default, because an unbounded list with hundreds of thousands of records could not be handled sensibly by either the server or the client. But choosing a pagination strategy decides more than just the page size: it determines how stable results stay when new records are inserted between two requests, and how expensive a query becomes on deep pages.
The most common strategy, offset pagination, is easy to understand and performs well enough for small to medium sized tables. With millions of rows or endpoints under heavy write load, though, it quickly becomes clear that a different pagination strategy is needed. API Platform offers both built in cursor based pagination and the option to implement a fully custom paginator.
Choosing the right pagination strategy from the start saves you a painful migration of the API contracts later. Switching from offset to cursor pagination changes the structure of the returned navigation links, which can introduce breaking changes for already live clients if it is not planned carefully.
2. Offset pagination: the default case in API Platform
With offset pagination, API Platform internally translates the page and itemsPerPage query parameters into an OFFSET and LIMIT on the database query. This is the default setting of every collection operation and needs no extra configuration. Through Hydra or JSON:API, the response additionally includes links to the first, last, previous, and next page, so clients can navigate the result set without any custom logic.
For most administrative interfaces and smaller data sets, offset pagination is the right choice because it allows jumping to an arbitrary page number. A user can jump directly to page 42, which is not readily possible with pure cursor pagination. This property makes offset pagination the preferred strategy for backoffice interfaces with page number navigation.
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
/**
* Standard offset pagination configuration for a resource.
*/
#[ApiResource(
operations: [
new GetCollection(
paginationEnabled: true,
paginationClientItemsPerPage: true,
paginationItemsPerPage: 20,
paginationMaximumItemsPerPage: 100,
),
],
)]
final class Invoice
{
public int $id;
public string $number;
public \DateTimeImmutable $issuedAt;
}
3. Where offset pagination hits its limits
The fundamental problem of offset pagination: at OFFSET 100000 the database first has to count and discard a hundred thousand rows before it can deliver the page you actually asked for. The deeper the page, the slower the query, because the cost grows linearly with the offset. On a table with several million rows this becomes noticeable quickly, even with a proper index on the sorting column.
The second problem concerns consistency: if a new record is inserted between two consecutive requests, the entire offset shifts by one position, which causes a client paging through the data to see the same record twice or miss another one entirely. For a feed that changes constantly, such as orders or log entries, this behavior quickly becomes a visible bug for the client.
4. Enabling cursor based pagination
API Platform natively supports cursor based pagination through paginationViaCursor. Instead of an offset, the client navigates using the value of a unique, sortable field, usually the id or a timestamp. Every response includes a cursor pointing at the last element of the current page, and the next request uses that cursor as a filter to continue exactly from there, without counting any previous rows.
This pagination scales linearly regardless of the position within the total data set, because the database jumps directly to the starting position via the index instead of skipping rows. The downside: a direct jump to an arbitrary page number is no longer possible, the client can only navigate forward or backward from the current cursor. For feeds, activity lists, and API consumers that systematically walk through all records, this is clearly the superior approach.
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
/**
* Cursor based pagination keyed on the immutable, ordered id column.
*/
#[ApiResource(
operations: [
new GetCollection(
paginationViaCursor: [
['field' => 'id', 'direction' => 'DESC'],
],
paginationPartial: true,
),
],
order: ['id' => 'DESC'],
)]
final class ActivityLogEntry
{
public int $id;
public string $action;
public \DateTimeImmutable $occurredAt;
}
5. Understanding keyset pagination with a proper index
Cursor based pagination in API Platform is at its core an implementation of keyset pagination: the database query gets a WHERE id < :cursor condition instead of an offset, combined with ORDER BY id DESC LIMIT :size. For this to stay performant, an index must exist on the sorting column, otherwise keyset pagination leads to full table scans as well.
For composite sorting, for example first by status, then by creation date, the keyset condition needs several fields in the cursor accordingly, so the ordering stays stable. API Platform supports multiple sort fields in the paginationViaCursor array, but it is important that a composite index in Doctrine mirrors the same field order, so the database can actually use the index instead of performing an additional in memory sort.
6. Building a custom paginator for external sources
As soon as data does not come from Doctrine but from an external API or a search index through a State Provider, pagination has to be rebuilt manually. API Platform expects an object implementing PaginatorInterface and providing methods like getCurrentPage(), getItemsPerPage(), and getTotalItems(). This metadata automatically flows into the Hydra or JSON:API response, without the client noticing anything about the internal source.
For cursor based pagination on an external source, you instead implement PartialPaginatorInterface, which does not need to know the total count, particularly relevant for search indexes like Elasticsearch where an exact total would be expensive to compute. The custom paginator in this case only passes through the next cursor value returned by the external source itself.
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\State\Pagination\PartialPaginatorInterface;
/**
* Wraps results from an external search index that returns its own cursor.
*/
final class SearchResultPaginator implements PartialPaginatorInterface, \IteratorAggregate
{
private array $items;
public function __construct(
array $items,
private readonly int $itemsPerPage,
private readonly ?string $nextCursor,
) {
$this->items = $items;
}
public function getIterator(): \Traversable
{
return new \ArrayIterator($this->items);
}
public function count(): int
{
return count($this->items);
}
public function getItemsPerPage(): float
{
return (float) $this->itemsPerPage;
}
public function getCurrentPage(): float
{
return 1.0;
}
}
7. Partial pagination for very large collections
Alongside cursor based pagination, API Platform offers paginationPartial as a lightweight option that skips the expensive COUNT(*) query at the Doctrine level. Instead of the exact total count, the response only tells you whether a next page exists. For infinite scroll interfaces this information is entirely sufficient and saves an additional, often costly counting query on very large tables.
This option can be combined with both offset and cursor pagination, and it is especially useful on tables with frequent writes, because an exact total in such a scenario only applies to the moment of the query anyway and may have already changed seconds later. Giving up the exact count here is not a loss of functionality, it is an honest reflection of the actual data situation.
8. Client ergonomics: Hydra links and OpenAPI parameters
Regardless of the pagination strategy chosen, API Platform automatically documents the available query parameters in the generated OpenAPI specification, so client developers can see without asking whether page, itemsPerPage, or a cursor parameter is expected. This automatic documentation is one of the biggest advantages over hand written REST endpoints, where pagination behavior is often only documented in a separate wiki.
For Hydra responses, API Platform additionally provides hydra:view with direct links to the next and previous page, so a well built client no longer needs any custom URL construction at all and simply follows the supplied link. This self description significantly reduces coupling between client and server, because the internal pagination format can change without the client having to adjust its URL logic.
9. Pagination strategies compared
The table below compares the three main pagination strategies in API Platform and shows which variant fits which situation.
| Criterion | Offset pagination | Cursor pagination | Recommendation |
|---|---|---|---|
| Jump to page N | Directly possible | Not directly possible | Offset for backoffice with page numbers |
| Performance on deep pages | Degrades as offset grows | Constant via index | Cursor for large tables |
| Consistency under writes | Duplicates or gaps possible | Stable per cursor | Cursor for feeds and logs |
| Total count known | Yes, via COUNT | Optional, via paginationPartial | Partial for very large tables |
| Setup effort | Default, no configuration | Sort field and index required | Offset as a safe starting point |
In practice, most API Platform projects start with default offset pagination and switch to cursor based pagination specifically once concrete performance or consistency problems appear. This pragmatic order avoids premature optimization without losing the ability to move to a cursor strategy later once the data volume requires it.
Mironsoft
Symfony and API Platform architecture for demanding APIs
Pagination that stays fast even with millions of records?
We analyze your API Platform endpoints, identify expensive offset queries, and implement cursor based pagination or custom paginators for external data sources.
Performance audit
Query analysis for pagination on large tables
Cursor migration
Safe switch from offset to cursor pagination without breaking changes
Custom paginators
Custom paginators for external APIs and search indexes
10. Summary
Offset pagination is the simple default case in API Platform and the right choice for smaller tables with direct page number navigation. Once tables grow or endpoints face heavy write traffic, cursor based pagination shows its strengths: constant performance regardless of position and stable results even under concurrent writes. For external data sources beyond Doctrine, you need a custom paginator implementing PaginatorInterface or PartialPaginatorInterface.
The choice of a pagination strategy should never be made blanket for an entire project, but per endpoint based on actual access patterns: backoffice lists benefit from offset, public feeds and activity lists from cursor pagination. Making this distinction early avoids breaking changes to the API contracts later on.
Pagination in API Platform: the essentials
Offset pagination
Default in API Platform, allows jumping directly to page numbers, gets slower at large offsets.
Cursor pagination
Enabled via paginationViaCursor, constant performance through the index, no direct page jump.
Partial pagination
paginationPartial skips the expensive COUNT query, ideal for infinite scroll and very large tables.
Custom paginators
PaginatorInterface or PartialPaginatorInterface for external sources beyond Doctrine.