Skip to content

docs: ETU-74448: Updated guidelines on pagination. - #118

Open
rikard-swahn wants to merge 8 commits into
mainfrom
ETU-74448-pagination
Open

docs: ETU-74448: Updated guidelines on pagination.#118
rikard-swahn wants to merge 8 commits into
mainfrom
ETU-74448-pagination

Conversation

@rikard-swahn

Copy link
Copy Markdown
Contributor

No description provided.

@rikard-swahn
rikard-swahn requested a review from a team as a code owner August 13, 2026 08:21
Comment thread doc/pagination-and-sorting.md Outdated
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.

@rikard-swahn rikard-swahn Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if sorting and filtering should be embedded in cursor or not. Need feedback here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread guidelines.md
Comment thread doc/guidelines.md Outdated
**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

@egrimstad egrimstad Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ja, men det må man jo nesten. Cursoren gir ikke lenger mening om man bytter filter vel?

@rikard-swahn rikard-swahn Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ser ikke helt use case for det? Men vet jo ikke en gang at det elementet cursoren peker på finnes med de nye filtrene.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@rikard-swahn
rikard-swahn requested a review from egrimstad August 14, 2026 06:55

@hw-knowit hw-knowit left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. This is too generic. pageSize is better. This must of course be synced with the cursor method.

Comment on lines +27 to +34
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` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +27 to +34
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` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parameter Type Description
totalCount integer 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. This must be an optional return value, and/or be allowed to be made approximate.

Comment on lines +38 to +54
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parameter Type Description
cursor string 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)?

Comment on lines +38 to +54
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parameter Type Description
cursor string 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 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread guidelines.md
**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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@hw-knowit

hw-knowit commented Aug 19, 2026

Copy link
Copy Markdown

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 Paging type implementation for clients in the future.


If we allow the application to decide whether to provide totalItems, we should probably decide on just one of 2.a or 2.b to limit scope. I think hasMore is a little awkward and requires some implementation to support (fetching n+1 and returning just n + hasMore) so maybe 2.b is preferable. I do think there is real value in allowing the application to omit totalItems, as detailed in another PR comment.

For 1, maybe either just number of pages, or just page size is sufficient without always needing to provide both.

Offset Pagination:

{
  items: [...],

  # EITHER (1) - Client sees exact no. pages/items:
  paging: {
    size: 100,
    pages: 13,
    items: 1250
  }

  # OR (2.a) - Client sees just whether result set is exhausted:
  paging: {
    size: 100,
    hasMore: true
  }

  # OR (2.b) - `items.size == paging.size` signals more data may exist. Client must query to know for sure.
  paging: {
    size: 100
  }
}

Keyset Pagination:

{
  items: [...],

  # Application decides:
  #   Either - Cursor existence means at least one more page exists
  #   Or - Cursor existence means there _may_ be more pages, client must fetch to check.
  paging: {
    cursor?: "<opaque data>"
  }
}

@hw-knowit

hw-knowit commented Aug 20, 2026

Copy link
Copy Markdown

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 self and next links which act as a sort of cursor already. I also like that they return the page number in the response.

https://docs.spring.io/spring-data/rest/reference/paging-and-sorting.html#paging-and-sorting.paging.prev-and-next-links

{
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/persons{&sort,page,size}",
      "templated" : true
    },
    "next" : {
      "href" : "http://localhost:8080/persons?page=1&size=5{&sort}",
      "templated" : true
    }
  },
  "_embedded" : {
  	… data …
  },
  "page" : {
    "size" : 5,
    "totalElements" : 50,
    "totalPages" : 10,
    "number" : 0
  }
}

@arnabkd

arnabkd commented Aug 20, 2026

Copy link
Copy Markdown

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:

  • Is there a "next" page?
  • What is the total number of items
    The client can also ask for first where first describes the number of items it wants after a certain cursor. Alternatively, last which is the number of items before a certain cursor. first and last can never be used together.

Additionally it would be useful to implement hasPreviousPage as well to allow for pagination both ways in situations where a client traverses a list of items backwards.

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 totalElements for instance.

@bnordli bnordli left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some comments from experience.


| Parameter | Type | Description |
|-----------|---------|--------------------------------------------------------------------|
| `page` | integer | Zero-based index of the page to retrieve. **MUST** be named `page` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Comment on lines +27 to +34
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` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants