Skip to main content
ANVISoftware Solutions
Lesson 10 of 18Intermediate18 min

Pagination

By the end of this lesson

Page large collections, and compare offset and cursor approaches.

GET /api/employees that returns every employee works perfectly with the forty rows in your development database. At forty thousand it returns a response measured in megabytes, holds a database connection while it does, and times out somewhere between your server and the caller.

Paging is the fix, and it belongs in the contract from the first release. Added later it changes the top-level shape of the response, which breaks every caller — the point the first lesson of this course made about bare arrays.

A paged request and its response
HTTP
GET /api/employees?page=2&pageSize=20 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
Content-Type: application/json

{
  "items": [
    { "id": 118, "fullName": "Asha Menon", "departmentName": "Support" },
    { "id": 119, "fullName": "Ben Okafor", "departmentName": "Support" }
  ],
  "page": 2,
  "pageSize": 20,
  "totalCount": 431,
  "totalPages": 22
}
  • The paging parameters are in the query string, because they narrow a read rather than identifying a resource.
  • The response echoes page and pageSize back. If the server clamped what was asked for, that is how the caller finds out.
  • totalCount and totalPages let a caller draw page numbers. They cost a second query, which is a real cost on a large table — offering them is a decision, not a default.
  • items is nested inside an object. Adding a field to this response later is additive and safe; adding one to a bare array is not possible.

There are two ways to express "the next page", and they fail in different places. Offset paging counts rows to skip. Cursor paging remembers the row you stopped at.

 Offset pagingCursor paging
How the caller askspage=3&pageSize=20, or skip=40&take=20An opaque token from the previous response: cursor=eyJpZCI6MTE5fQ
Jump to page 57Yes, directlyNo. You can move forward from where you are, and often backward, but not to an arbitrary page
Total number of pagesAvailable, at the cost of a count queryUsually not offered, because there is no page number to count towards
When data changes mid-readDrifts. Inserts and deletes shift rows between pages, so rows are skipped or repeatedStable. The position is a row, so new rows do not move it
Cost at a high offsetThe database still walks the rows it skips, so page 500 is slower than page 1Flat, because the index seeks straight to the position
Effort to implementLow. Two integers and Skip/TakeHigher. Needs a deterministic sort, an encoded token, and care over what goes in it
FitsAdmin screens with page numbers, moderate data, human browsingInfinite scroll, feeds, exports, and any table large enough for deep offsets to hurt

The drift problem is worth making concrete, because it sounds theoretical until it produces a bug report. Employees are ordered by surname. A caller reads page 1, rows 1 to 20. Before it reads page 2, someone hires an employee whose surname sorts into row 5. Every later row shifts down by one. Page 2 now starts with the row that used to end page 1, so the caller sees one row twice and, if a deletion happens instead, misses one entirely.

For a person clicking through an admin screen, a repeated row is a curiosity. For a job exporting every employee into a payroll file, a skipped row is a person who does not get paid. That difference is the whole basis for choosing between the two approaches.

Cursor paging avoids it by asking a different question. Instead of "skip 20 rows", it asks "give me rows after Okafor, id 119". Inserting a row before that position changes nothing about where the next page starts. The technique is also called keyset pagination, and the cursor is usually the sort key plus a unique tiebreaker, encoded so callers treat it as opaque rather than something to construct themselves.

Offset paging with the page size under your control
C#
const int DefaultPageSize = 20;
const int MaxPageSize = 100;

app.MapGet("/api/employees", async (
    AppDbContext db, CancellationToken ct,
    int page = 1, int pageSize = DefaultPageSize) =>
{
    var safePage = Math.Max(page, 1);
    var safeSize = Math.Clamp(pageSize, 1, MaxPageSize);

    var query = db.Employees
        .OrderBy(e => e.Surname)
        .ThenBy(e => e.Id);

    var totalCount = await query.CountAsync(ct);

    var items = await query
        .Skip((safePage - 1) * safeSize)
        .Take(safeSize)
        .Select(e => new EmployeeListItem(e.Id, e.FullName, e.Department!.Name))
        .ToListAsync(ct);

    return Results.Ok(new
    {
        items,
        page = safePage,
        pageSize = safeSize,
        totalCount,
        totalPages = (int)Math.Ceiling(totalCount / (double)safeSize)
    });
});
  • page and pageSize have defaults, so a caller that sends neither gets the first page rather than everything.
  • Math.Clamp is the line that matters most in this sample. Whatever arrives — 0, -5, 50000 — the server uses a value it chose. Without it, pageSize is a caller-controlled instruction about how much work your database does.
  • ThenBy(e => e.Id) is not decoration. Surnames repeat, and a database is free to return tied rows in any order, so without a unique tiebreaker two requests for the same page can return different rows even when nothing changed.
  • CountAsync is a second query over the whole filtered set. On a large table it can cost more than fetching the page, which is why some APIs drop totalCount or compute it only on the first page.
  • Skip translates to OFFSET. The database still has to pass over the skipped rows, so deep pages get slower — the cost that cursor paging removes.

Summary

  • Paging belongs in the contract from the start, because adding it later changes the response shape
  • Offset paging is simple and drifts when rows are inserted or removed between pages
  • Cursor paging is stable and cheap at depth, and cannot jump to an arbitrary page
  • Always sort by something unique last, or tied rows move between pages on their own
  • Enforce a default and a maximum page size, and report the size you actually used

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Choose the approach

Two features need paging. One is an admin table with page numbers and a total, over roughly 5,000 departments that change a few times a week. The other is a nightly job that exports all 400,000 orders to a finance system.

Choose offset or cursor paging for each, and give the reason that decides it.

Show solution

Offset paging for the admin table. Page numbers and a total are what the screen needs, the data is small enough that deep offsets do not hurt, and drift between page 3 and page 4 is a curiosity rather than a fault.

Cursor paging for the export. The deciding reason is correctness, not speed: an insert during a long export shifts every later row, so offset paging can skip an order entirely, and a finance system that is missing an order is a real problem.

The performance argument supports the same choice. Walking to offset 380,000 makes the database pass over rows it will discard, on every request, while a cursor seeks directly to its position.

Try it yourself

Reproduce the drift

Page a table of at least 30 rows, 10 per page, ordered by a non-unique column such as surname.

Read page 1. Insert a row that sorts near the top. Read page 2. Compare what you get with what page 2 would have been before the insert.

Then remove the unique tiebreaker from the ordering and read the same page several times.

Show solution

With the insert, the last row of page 1 reappears as the first row of page 2. Nothing is broken — offset paging counted rows, and the number of rows before your position changed.

Without the tiebreaker you may see rows move between pages with no data changing at all. The database is not obliged to order tied rows consistently, and on a larger table or a different plan it will not.

Both observations argue for the same habit: always sort by something unique last, and treat offset paging as a browsing tool rather than a guarantee of complete coverage.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A job exports every order by walking offset pages while new orders are being created. What can go wrong?
Why does an endpoint need a maximum page size even when its callers are internal and well behaved?

Saved in this browser only.