docs: ETU-74448: Updated guidelines on pagination. - #118
Conversation
| Cursor-based pagination is based on a `cursor` that is created when handling requests from the client, and it is returned to the client (in the response body). | ||
| The cursor points to the next item coming after the items that you are returning. | ||
|
|
||
| Pagination direction, number of items to get and filters are **not** embedded in the cursor — they are sent separately by the client. |
There was a problem hiding this comment.
I am not sure if sorting and filtering should be embedded in cursor or not. Need feedback here.
There was a problem hiding this comment.
Depends on what you mean. When implementing a opaque cursor, usually that cursor only exists inside that specific filter and sorting combination. In other words:
Request A with myrequest?filter=nameIncludesFoo&sortDirection=ASC generates cursors that will never be found in: Request B with with myrequest?filter=nameIncludesFoo&sortDirection=DESC. Same goes for if the filtering changes.
There was a problem hiding this comment.
Of course, if you don't include filters + sorting, you can always achieve cursor pagination, but you won't be able to sort or filter server side
| **OR** query parameters offset (zero based) and limit (number of items) | ||
| - :eyes: If you implement sorting, you **SHOULD** use query parameter "sort". Sorting can be done on multiple levels, and sort order (desc / asc) is also specified, like so: `sort=<field1>,<asc|desc>&sort=<field2>,<asc|desc>` | ||
| - **TODO**: Requirements for response format for pagination and sorting | ||
| ### 6.1 Pagination |
There was a problem hiding this comment.
Kanskje vi kan beholde filtering, sorting og pagination under samme del? De har jo med hverandre å gjøre. Blant annet er det noe å tenke på om man forventer at man sender med de samme filter/sort parametrene når man spesifiserer cursor. eller embeddes filtreringene inni cursor?
Da slipper vi også å endre på de andre underoverskriftene (altså 6.2 til 6.3) som er fint for å ikke knekke eksisterende lenker.
There was a problem hiding this comment.
Ja, enig att vi kan ha kvar det i samma del.
Ref embedding av filtrering i cursor, jag förstår inte varför folk gör det, gör man det? Om man ändrar filter måste man börja om från början igen? Jag kanske missförstår något här.
There was a problem hiding this comment.
Ja, men det må man jo nesten. Cursoren gir ikke lenger mening om man bytter filter vel?
There was a problem hiding this comment.
Hmm, ja hur blir det... cursorn pekar på en specifik position. Jag tänkte att man kan starta från den positionen och ändå uppdatera filter?
There was a problem hiding this comment.
Ser ikke helt use case for det? Men vet jo ikke en gang at det elementet cursoren peker på finnes med de nye filtrene.
There was a problem hiding this comment.
Nej, det kanske inte är ett usecase, men elementet behöver inte finnas heller tänker jag, du kan fortfarande ta element före eller efter det. Men jag har inte använt cursor själv, så det kanske inte ger mening i praktiken.
hw-knowit
left a comment
There was a problem hiding this comment.
A good start!
I think the guide needs a lot of massaging both in content and structure to read clearly as an introduction to keyset pagination for readers who may not have heard of it before, and we will need some discussion on the exact format expected of all applications.
Many of my comments aren't necessarily to be taken as "this must be changed exactly so", but to open up a discussion with a concrete suggestion to start off.
I'm excited to see this implemented.
| | Parameter | Type | Description | | ||
| |-----------|---------|--------------------------------------------------------------------| | ||
| | `page` | integer | Zero-based index of the page to retrieve. **MUST** be named `page` | | ||
| | `size` | integer | Number of items per page. **MUST** be named `size` | |
There was a problem hiding this comment.
Is size a too generic term to hijack for pagination across all endpoints? Even endpoints without pagination will need to consider such a field to avoid confusion with paginated endpoints.
It is also not entirely obvious to me that size means "size of a page". In the example 5 lines down size does not obviously read as "page size" to me.
Suggestion: pageSize or perPage.
Counterargument: The Spring default is page and size.
There was a problem hiding this comment.
I agree. This is too generic. pageSize is better. This must of course be synced with the cursor method.
| The response format will vary between APIs, but a typical response at least include the items along with the total number of items that can be paginated: | ||
|
|
||
| The response **MUST** contain the following fields: | ||
|
|
||
| | Parameter | Type | Description | | ||
| |-----------|---------|------------------------------------------------------------------------------------| | ||
| | `items` | array | **SHOULD** be named `items` unless there is a specific reason to use another name. | | ||
| | `totalCount` | integer | The total number of items across all pages. **MUST** be named `totalCount` | |
There was a problem hiding this comment.
The response format will vary between APIs
This seems to imply that applications can choose how to represent paginated responses. I am not convinced there is value in allowing applications to stray from guidelines here. Other than the fact that these various response formats already exist, why would an application need to use any other name than items (or whatever is decided on)? If all applications follow this exact format all client code can be greatly simplified to everyone's benefit.
Apart from the point about the actual formatting, I think the wording here can be simplified to just state the exact requirements without implying variations.
| The response format will vary between APIs, but a typical response at least include the items along with the total number of items that can be paginated: | ||
|
|
||
| The response **MUST** contain the following fields: | ||
|
|
||
| | Parameter | Type | Description | | ||
| |-----------|---------|------------------------------------------------------------------------------------| | ||
| | `items` | array | **SHOULD** be named `items` unless there is a specific reason to use another name. | | ||
| | `totalCount` | integer | The total number of items across all pages. **MUST** be named `totalCount` | |
There was a problem hiding this comment.
Parameter Type Description totalCountinteger The total number of items across all pages. MUST be named totalCount
Counting the total elements can be an expensive operation and in many cases (Spring default for Postgres) require an additional query for counting. This query can take much longer than the base page fetch if the result set is large (counting in Postgres is slow even when indexed). This concern lead to me researching keyset pagination to begin with.
Do we want to allow a halfway solution to help with backend performance without going all the way to implementing keyset pagination?
Suggestion: Make the format either totalCount: int or hasMore: boolean. Maybe mention the performance implications and state that the app **SHOULD** supply the count, but list some acceptable cases where omitting it makes sense. This is similar to keyset pagination, but requires less backend changes to work. A client can still attempt to request page: 10 directly, but may not know for certain that it exists before having fetched page 9.
"count" is not my favorite wording for these cases. In many standard libraries, count on a collection implies filtering or processing in a way that differs from size or length.
Suggestion: totalItems.
Should we consider including more page-related info like totalPages or pageSize in the response? totalPages can be a minor DX win so the client doesn't need to calculate the number of pages themselves; and pageSize may come in handy if the default page size is smaller than the one client supplied. Consider without these, if the client attempts pageSize: 1000 but the application limits to max 100. Without totalPages or pageSize the client may think that there are totalCount / 1000 pages but in reality there is totalCount / 100. If the response contains totalPages (and maybe pageSize) we can eliminate (or alleviate) such problems. This also lets the client self-correct if the defaulted page size is smaller than expected.
Suggestion: Include totalPages and pageSize (when totalCount also exists)? It's not an unexpected helper to find in such structures elsewhere. Let's the client skip the size * currentPage < totalItems charade, so a minor DX win (even if they must supply size again on the next query).
If this suggestion is accepted, maybe consider a whole new page or paging or pagination sub-structure instead of putting all these keys directly in the root object.
There was a problem hiding this comment.
I agree. This must be an optional return value, and/or be allowed to be made approximate.
| This strategy is based on these query parameters: | ||
|
|
||
| | Parameter | Type | Description | | ||
| |-----------|---------|-----------------------------------------------------------------------------------| | ||
| | `cursor` | string | An opaque string pointing to the **next** item to get. **MUST** be named `cursor` | | ||
| | `size` | integer | Number of items per page. **MUST** be named `size` | | ||
|
|
||
| Cursor-based pagination is based on a `cursor` that is created when handling requests from the client, and it is returned to the client (in the response body). | ||
| The cursor points to the next item coming after the items that you are returning. | ||
|
|
||
| Sorting parameters, number of items (`size`) to get and filters are **not** embedded in the cursor — they are sent separately by the client. | ||
| This allows the client to change direction or filters independently without obtaining a new cursor. | ||
|
|
||
| On the next request from the client, the cursor is sent back to the service (along with any other parameters). | ||
| The service returns the requested items and calculates a new cursor pointing to the next item. In this way, the client can paginate through items. | ||
|
|
||
| Clients should not inspect, parse, or construct cursors themselves — a cursor should be treated as an opaque string with an unknown and possibly changing format. |
There was a problem hiding this comment.
Parameter Type Description cursorstring An opaque string pointing to the next item to get. MUST be named cursor
Whether the cursor points to the next item or the currently last item should be up to the backend implementation and isn't directly relevant information in the query param description. The interface doesn't care about the contents of the cursor value. The backend should be able to choose whichever value makes the most sense.
Suggestion: Reword to something akin to "An opaque string identifying the next page of results. Supplied by the backend in the response body for the previous page. MUST be named cursor.
A later section with more implementation focus can talk about actual cursor contents.
Should this table mention at the outset that cursor is cursor?: string (optional, but must be string if present)?
| This strategy is based on these query parameters: | ||
|
|
||
| | Parameter | Type | Description | | ||
| |-----------|---------|-----------------------------------------------------------------------------------| | ||
| | `cursor` | string | An opaque string pointing to the **next** item to get. **MUST** be named `cursor` | | ||
| | `size` | integer | Number of items per page. **MUST** be named `size` | | ||
|
|
||
| Cursor-based pagination is based on a `cursor` that is created when handling requests from the client, and it is returned to the client (in the response body). | ||
| The cursor points to the next item coming after the items that you are returning. | ||
|
|
||
| Sorting parameters, number of items (`size`) to get and filters are **not** embedded in the cursor — they are sent separately by the client. | ||
| This allows the client to change direction or filters independently without obtaining a new cursor. | ||
|
|
||
| On the next request from the client, the cursor is sent back to the service (along with any other parameters). | ||
| The service returns the requested items and calculates a new cursor pointing to the next item. In this way, the client can paginate through items. | ||
|
|
||
| Clients should not inspect, parse, or construct cursors themselves — a cursor should be treated as an opaque string with an unknown and possibly changing format. |
There was a problem hiding this comment.
Parameter Type Description cursorstring An opaque string pointing to the next item to get. MUST be named cursor
Possible alternative name: nextPage. I've seen both. "cursor" may imply something about the backend implementation (e.g. stateful cursor implementations in DB). I don't feel strongly on this one, I do like "cursor" for it's simplicity.
| | **Jump to arbitrary page** | ✅ Supported | ❌ Not supported — only sequential traversal | | ||
| | **Consistency under data changes** | ⚠️ Inserts/deletes between requests may cause duplicates or missing items | ✅ Stable — cursor anchors position in the data set | | ||
| | **Performance on large data sets** | ⚠️ `OFFSET` queries degrade as page number grows, because the database must scan and discard all rows before the offset | ✅ Constant-time lookups | | ||
| | **Sharded / NoSQL databases** | ⚠️ Difficult to implement efficiently | ✅ Well suited — relies on key ordering rather than global offset | |
There was a problem hiding this comment.
This point needs improving or revising. A NoSQL database that supports ordering and filtering has no problems with either strategy, though performance may be an issue in both cases. Sharded databases are much more work in both cases, but if you chose that approach you should already know what you are doing and you probably won't need this warning.
| **OR** query parameters offset (zero based) and limit (number of items) | ||
| - :eyes: If you implement sorting, you **SHOULD** use query parameter "sort". Sorting can be done on multiple levels, and sort order (desc / asc) is also specified, like so: `sort=<field1>,<asc|desc>&sort=<field2>,<asc|desc>` | ||
| - **TODO**: Requirements for response format for pagination and sorting | ||
| ### 6.1 Pagination and Sorting |
There was a problem hiding this comment.
This section should probably include the response format and a very basic usage guide as a quick intro to consumers who just want to use a paginated API.
|
|
||
| As a rule of thumb, cursor pagination **SHOULD** be used unless the number of items is small, inserts and deletes are infrequent, and jumping to specific pages must be supported. | ||
|
|
||
| ## Sorting |
There was a problem hiding this comment.
Maybe this belongs in a document by itself? Some of what is mentioned in this section deserves expanding on like we are doing with pagination in this PR.
Suggestion for this PR: Move Sorting (untouched) to its own document. For now, just mention here that any application implementing pagination **MUST** have a default sort and **MAY** support sorting parameters.
| | **Performance on large data sets** | ⚠️ `OFFSET` queries degrade as page number grows, because the database must scan and discard all rows before the offset | ✅ Constant-time lookups | | ||
| | **Sharded / NoSQL databases** | ⚠️ Difficult to implement efficiently | ✅ Well suited — relies on key ordering rather than global offset | | ||
|
|
||
| As a rule of thumb, cursor pagination **SHOULD** be used unless the number of items is small, inserts and deletes are infrequent, and jumping to specific pages must be supported. |
There was a problem hiding this comment.
I think it should be emphasized how both client needs and performance concerns govern which pagination strategy you choose.
As I see it, the best argument for offset pagination is jumping to an exact page (like pages in a forum where the user may want to track page numbers, or any case where someone might say "look at page N") and any case where knowing the exact number of pages or total items is relevant. Also it must be said that offset pagination is very simple to implement.
The actual number of items queried is a performance concern, but it should maybe be noted that the difference in performance doesn't come into effect until you're in the multi-million range possibly with joins involved. Any unindexed query is doomed no matter the strategy you choose.
For any search interface it should again be up to whether it makes sense to list total number of items.
I think it makes sense for teams that deal with pagination to understand keyset pagination, but I would probably still default to offset pagination for new apps and simple apps, unless and until you know that the app will deal with enormous result sets or complex queries or large table joins or any kind of "infinite scroll" type usage.
As an example of where we would choose keyset pagination in Team Salg is any search interface where the user/client may themselves pick arbitrary search parameters, especially where some of those search parameters require joining into other tables. A search in one of our apps may join a 100M rows table into a 200M rows table into another 100M rows table -- for pages 1-3 this is usually fine, but at a certain threshold the database decides that it needs to hash every join key in all 3 tables to get page n+1 and suddenly the query takes 20 minutes to complete. As such we would like to implement keyset pagination by default in all search endpoints to unify our search interface and to get ahead of any performance concerns.
|
|
||
| | Parameter | Type | Description | | ||
| |-----------|---------|--------------------------------------------------------------------| | ||
| | `page` | integer | Zero-based index of the page to retrieve. **MUST** be named `page` | |
There was a problem hiding this comment.
Zero-based index
I very much like this. Others may very much not. Should probably be checked to see what is more prevalent in our apps and on the internet.
There was a problem hiding this comment.
I believe offset is a cleaner solution (it is named "Offset pagination" after all). The difference is minor, but offset is more flexible:
Let's say a user opens a page with 20 rows, and navigates to page 3 to find something, then realizes the data set it larger than expected, and needs to look further and changes the page size to 100 rows. (Not all, but some UI elements allows to resize their tables.)
With a page parameter, this new result of 100 rows (starting from position 40) is impossible to retrieve with one request. With an offset parameter, is it just a matter of changing the size parameter.
Also, an offset parameter is inherently zero-based, so there will be no surprises there.
|
My suggested interface in summary. Names debatable of course. While writing these out side by side, it seemed natural to provide a paging object for both interfaces so they appear more similar. If we are to put 4 (maybe) required fields in root the response gets pretty messy, and doing it like this makes it easy to provide a If we allow the application to decide whether to provide For Offset Pagination: Keyset Pagination: |
|
And an additional consideration: Spring has HATEOAS which returns the following structure. With the number of Spring apps in Entur it may be wise to allow (or enforce..?) pagination responses to look like this. Note the |
|
In GraphQL, cursor pagination is the norm and there the spec looks somewhat like this which enables the following questions to be answered for the client:
Additionally it would be useful to implement In the context of REST APIs, I would probably do it similar to what is described above by @hw-knowit but I might make some of the fields optional. It might cost a lot to calculate |
|
|
||
| | Parameter | Type | Description | | ||
| |-----------|---------|--------------------------------------------------------------------| | ||
| | `page` | integer | Zero-based index of the page to retrieve. **MUST** be named `page` | |
There was a problem hiding this comment.
I believe offset is a cleaner solution (it is named "Offset pagination" after all). The difference is minor, but offset is more flexible:
Let's say a user opens a page with 20 rows, and navigates to page 3 to find something, then realizes the data set it larger than expected, and needs to look further and changes the page size to 100 rows. (Not all, but some UI elements allows to resize their tables.)
With a page parameter, this new result of 100 rows (starting from position 40) is impossible to retrieve with one request. With an offset parameter, is it just a matter of changing the size parameter.
Also, an offset parameter is inherently zero-based, so there will be no surprises there.
| | Parameter | Type | Description | | ||
| |-----------|---------|--------------------------------------------------------------------| | ||
| | `page` | integer | Zero-based index of the page to retrieve. **MUST** be named `page` | | ||
| | `size` | integer | Number of items per page. **MUST** be named `size` | |
There was a problem hiding this comment.
I agree. This is too generic. pageSize is better. This must of course be synced with the cursor method.
| | Parameter | Type | Description | | ||
| |-----------|---------|--------------------------------------------------------------------| | ||
| | `page` | integer | Zero-based index of the page to retrieve. **MUST** be named `page` | | ||
| | `size` | integer | Number of items per page. **MUST** be named `size` | |
There was a problem hiding this comment.
Implementations should implement and strive to document a default and a maximum page size.
(We don't want an omitted or 1 000 000 page size to accidentally or maliciously overwhelm the server.)
| The response format will vary between APIs, but a typical response at least include the items along with the total number of items that can be paginated: | ||
|
|
||
| The response **MUST** contain the following fields: | ||
|
|
||
| | Parameter | Type | Description | | ||
| |-----------|---------|------------------------------------------------------------------------------------| | ||
| | `items` | array | **SHOULD** be named `items` unless there is a specific reason to use another name. | | ||
| | `totalCount` | integer | The total number of items across all pages. **MUST** be named `totalCount` | |
There was a problem hiding this comment.
I agree. This must be an optional return value, and/or be allowed to be made approximate.
|
|
||
| ## Pagination | ||
|
|
||
| When implementing pagination, you **MUST** use either Offset Pagination or Cursor Pagination, on the formats detailed below. |
There was a problem hiding this comment.
I think the order should be swapped, and noted that Cursor Pagination should be the first choice, as it in most cases can be done much more efficiently on the server.
"WHERE id >= cursor", instead of "OFFSET offset", or for data storage that doesn't support offset at all.
No description provided.