Pagination in REST APIs: Cursor vs Offset
Offset paging is easy and drifts at scale; cursor paging stays stable for growing data. How each works, and how to design the metadata.
Netbay Cloud Team
Netbay Engineering
Every list endpoint eventually needs a limit. Without pagination, a list-all request either returns so much data that clients break, or it caps at a fixed number and silently hides the rest. The two classic answers are offset paging — skip N rows and take M — and cursor paging — start after an opaque token. They feel interchangeable until your data grows; choosing wrong means rewrites later. This post lays out how each works and when to pick which.
Offset pagination
Offset paging is the easiest to build and the easiest to explain: page=2, or offset=40 paired with a limit. The database translation is direct — LIMIT 20 OFFSET 40 — so it is the natural first implementation. It supports arbitrary jumps: ask for page 47 and you get page 47. That is genuinely useful in admin tables and other small datasets where everything fits in memory anyway.
The problems appear at scale. A deep offset forces the database to scan past every row before the target, so page 10,000 is painfully slow regardless of indexes. Worse, offset paging has no memory of what you have already seen. If a row is inserted while someone pages through the list, one row is shown twice; if a row is deleted, one row is skipped. For feeds, logs, and anything live, that drift is a correctness bug your users will notice.
Cursor pagination
Cursor pagination trades jump-to-page for stability. The server hands back an opaque token — typically an encoded position such as the last seen primary key plus its sort key — and the client asks for everything after that. The database translation is a keyset predicate: WHERE created_at > cursor ORDER BY created_at LIMIT 20. Because the comparison is index-friendly, deep pages cost the same as shallow ones.
Insertions and deletions no longer matter: the cursor is an absolute anchor, so nothing is shown twice or skipped as the table changes underneath. That makes cursors the natural fit for activity feeds, event streams, audit logs, and notification lists. The price is that arbitrary jumps and numbered pages disappear; you can only walk forward. Keep cursors opaque to clients, give them a short expiry so stale tokens fail loudly instead of confusingly, and document the sort order you page by — a cursor without a stable order is meaningless.
Response metadata both ways
Whichever you choose, the client needs three things in every response: the items, a way to fetch the next page, and a way to know whether more pages exist. Define the fields once and reuse them everywhere pagination appears.
{
"data": [
{ "id": "inv_1001", "amount_inr": 120, "issued_at": "2026-04-01T00:00:00Z" },
{ "id": "inv_1002", "amount_inr": 499, "issued_at": "2026-04-02T00:00:00Z" }
],
"paging": {
"limit": 2,
"has_more": true,
"next": "MTg0Ng=="
}
}total is expensive and often optional; omit it when you cannot compute it cheaply, or return it only on the first page. has_more plus next saves clients a pointless extra request that exists purely to discover the end.
# offset style: jump anywhere, but drift-prone on live data
curl -s "https://api.example.in/v1/invoices?offset=40&limit=20"
# cursor style: walk forward, stable under inserts and deletes
curl -s "https://api.example.in/v1/invoices?limit=20&after=MTg0Ng=="-- offset: the database rescans the previous 40 rows on every request
SELECT * FROM invoices ORDER BY created_at LIMIT 20 OFFSET 40;
-- cursor: index-friendly keyset lookup, same cost at any depth
SELECT * FROM invoices
WHERE created_at > :last_seen
ORDER BY created_at LIMIT 20;Choosing
- Offset: small, mostly-static datasets, admin tables, and any interface that must jump to a numbered page.
- Cursor: everything that grows — logs, feeds, event streams, transactions — and anything your clients will page to the end of.
A reasonable heuristic: if the table can comfortably be sorted in full on a single database node and rarely changes, offset is fine. The moment the list is append-heavy, user-facing, or long enough to scroll for minutes, cursor wins. You can also cap offset depth defensively: reject offsets past a large threshold and direct callers to the cursor interface before they reach it.
Takeaway
Cursor pagination is the better default for any list that will outgrow a comfortable sort. Offset paging is not a sin — it is a scope decision — but scope has a habit of expiring. Pick the cursor for anything that grows, document the paging contract once, and save your future self a migration.
Reading a production list-shaped contract is easy at Netbay: log in at netbayhosts.in, pull the public VM plan list with key and secret auth, and let that simple, predictable response be the template for the pagination contract you design yourself.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance