While there are many ways to paginate data, the most common pattern found nowadays is cursor-based.
A cursor indicates how to find the position of an element within a collection.
Here's an example cursor returned from DynamoDB.
{ hash: "username", range: "01-01-2000" }
It's not uncommon to base64 encode cursors to discourage tampering (as they are intended to resemble state).
eyJoYXNoIjoidXNlcm5hbWUiLCJyYW5nZSI6IjAxLTAxLTIwMDAifQ==
There's no single standard for cursor pagination. Here are a few examples:
While this isn't a full coverage of all use cases, here are some common flows for pagination.
Here's a generic example of how uni-directional pagination can take place.
Client requests a page and specifies the desired page size
GET /users?pageSize=3
Backend returns the data along with additional metadata.
{
data: [
{ id: 1 },
{ id: 2 },
{ id: 3 },
],
hasMore: true,
endCursor: 'cursor-3'
}
Client makes pagination request again but also provides cursor
GET /users?pageSize=2&cursor=cursor-3
Note: Once the client gets the next page, it needs to find a way to aggregate said data into a single list
Some more recent specs provide cursors for all nodes, not just the first/last. Here's why that might be necessary.
Client requests a page and specifies the desired page size
GET /users?pageSize=3
Backend returns the data along with additional metadata.
{
data: [
{ cursor: 'cursor-1', data: { id: 1 } },
{ cursor: 'cursor-2', data: { id: 2 } },
{ cursor: 'cursor-3', data: { id: 3 } },
],
hasMore: true,
endCursor: 'cursor-3'
}
Client deletes the last element from the list
DELETE /users/3
Client fetches next page using cursor from (now) last node in the collection
GET /users?pageSize=3&cursor=cursor-2
Note: If single cursor pagination is used, some issues could be encountered from trying to use a cursor that references a non-existent node
It's becoming more common to see bi-directional pagination for large lists. With a single order approach, no assumptions are made about how data will be presented.
Client fetching from the front of the list
GET /users?first=2
Backend returning list in default order
{
data: [
{ cursor: 'cursor-1', data: { id: 1 } },
{ cursor: 'cursor-2', data: { id: 2 } },
]
}
Client fetching from the back of the list
GET /users?last=2
Backend returning data (same order)
{
data: [
{ cursor: 'cursor-4', data: { id: 4 } },
{ cursor: 'cursor-5', data: { id: 5 } },
]
}
Note: By having data in a consistent order, it becomes trivial to fetch from both-ends of a page (as you see with GitHub comments for example).
Sort order is left to the responsibility of the client.
Some specs flip data ordering when fetching from the back of a list. This might save some presentational work at the cost of making it slightly more challenging to merge previously retrieved pages.
Client fetching from the front of the list
GET /users?size=2&order=forward
Backend returning list in default order
{
data: [
{ cursor: 'cursor-1', data: { id: 1 } },
{ cursor: 'cursor-2', data: { id: 2 } },
]
}
Client fetching from the back of the list
GET /users?last=2
Backend returning data (different order)
{
data: [
{ cursor: 'cursor-5', data: { id: 5 } },
{ cursor: 'cursor-4', data: { id: 4 } },
]
}
First off - I think the most important thing here is that we don't make our own pagination spec. This is a solved problem, and while each solution has it's benefits/flaws, I think it would be naive to think we could come up with something better.
IMHO any spec which has the following should work:
- Cursors for all nodes (multi cursor)
- Bi-directional pagination (preferrably single order)
- β Single cursor
- π Bi-directional pagination (ordered)
- Adoption: unknown
- β Cursors for all nodes
- π Bi-directional pagination (ordered)
- β Attatches cursors to nodes under a
meta
attribute (could lead to conflicts) - Adoption: unknown
- β Cursors for all nodes
- β Bi-directional pagination (single order)
- π wraps nodes in an
edge
(extra nesting) - π designed with GraphQL in mind
- Adoption: popular