Rest API Paging Strategy

There is a lot written about paging strategies for REST API’s. This is my simple and quick take on the subject and what I generally implement when rolling my own APIs.

Any API that returns a collection should have some form of return result limiting. This is to avoid killing your web servers, database servers, networks and avoiding a super easy distributed denial of service (DDoS) attack.

I don’t cover other optimisations like filtering and sorting, although these do have a major impact too.

There are two major API standards/frameworks that I would follow when implementing my own paging strategy:

Adding to that, there are two primary pagination styles:

  • Offset
    • Most common. Set the number of records in a page and then the record offset to use.
    • If there are more results, the server also returns some metadata that contains information about the current page, such as the total number of pages and the link for the next page.
  • Keyset / Cursor based
    • Less common. Harder to program. Uses a specific cursor, which is a unique identifier for a specific item in the list, then returns this record and the next specified number of results.
    • If there are more results, the server also returns a cursor for the next page.
Standard / FrameworkPrimary Paging MethodQuery Properties
ODataOffset– skip (the offset from the beginning of the results)
– top (the number of records it wants to retrieve in each page)
GraphQLKeyset / Cursor based– first (the number of records to return)
– after (specify the cursor of the record to show after)
Table showing high-level paging methods

My General Principals

  1. Use Offset paging unless, you have large data and performance challenges.
  2. Use ‘skip’ and ‘top’ for the parameters, as this matches OData which you may want to implement later anyway.
  3. Always have a default page size (‘top’) and use it if ‘top’ is not specified.
  4. Always return a total query set count (integer) by default. e.g., $count defaults to true, but can be turned off, if necessary, by passing false.
  5. If more records exist, then always return a ‘nextLink’ URL
  6. Put the results array into the ‘value’ field to match that of the OData.

Response Structure and Paging Metadata

  • nextLink
  • totalCount

Example OData Return Results

{
    "@odata.context": "http://localhost:5000/odata/$metadata#Books",
    "@odata.count": 100,
    "@odata.nextLink": "http://localhost:5000/odata/Books?$count=true&$top=2&$skip=1",
    "value": [
        {
            "Id": 1,
            "ISBN": "978-0-321-87758-1",
            "Title": "Essential C#5.0",
            "Author": "Mark Michaelis"
        },
        {
            "Id": 2,
            "ISBN": "063-6-920-02371-5",
            "Title": "Enterprise Games",
            "Author": "Michael Hugos",
        }
    ]
}

Example of hand rolled API Return Results

{
    "count": 100,
    "nextLink": "http://localhost:5000/Books?$count=true&$top=2&$skip=1",
    "value": [
        {
            "Id": 1,
            "ISBN": "978-0-321-87758-1",
            "Title": "Essential C#5.0",
            "Author": "Mark Michaelis"
        },
        {
            "Id": 2,
            "ISBN": "063-6-920-02371-5",
            "Title": "Enterprise Games",
            "Author": "Michael Hugos",
        }
    ]
}

Data Changes Complications

There are a few complexities that may be of a concern if you are requiring an exact immutable record set. In most cases, this is not critical, but could be in a financial or audit type scenario. That is the complete recordset will need to be locked and remain unaltered during all the paging activities.

An example would be where a field in the recordset must add up exactly to a total at a specific point in time.

As most dataset are dynamic, new records can be added and removed at any point and that means you can’t guarantee to not return the same record in different pages or that the records on a given page/offset will remain the same, and that the total count does not change.

There are several solutions to this problem, and I will only suggest the easiest here and it’s not perfect either.
The dataset will require a created timestamp field, as well as a soft delete ‘isDeleted’ or ‘deletedAt’ type field. Then you can return all the records (including the soft deleted ones) prior to a specified creation time (i.e. the time of the first request in the sequence).

This would ensure consistency in the records returned, but not necessarily the values in those records.

References

Pagination | GraphQL

Pagination – OData | Microsoft Learn

PageSize, Top and MaxTop – OData | Microsoft Learn

Add NextPageLink and $count for collection property – OData | Microsoft Learn

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.