Every list endpoint needs pagination — the only question is whether you design it deliberately or discover it when a table hits a million rows. This guide builds a pagination contract that's predictable for clients and cheap for your database, and shows exactly when to switch from offset to cursor.
Define the contract first
Pagination is an API contract, so pin down the shape before the implementation. Clients send how many and where from; you return the data plus page metadata so they can request the next page without guessing.
GET /orders?limit=20&sort=-createdAt
{
"data": [ /* up to 20 orders */ ],
"page": {
"limit": 20,
"nextCursor": "eyJpZCI6MTQzfQ",
"hasMore": true
}
}
Return hasMore and a nextCursor (or a total for offset) so the client never has to fetch an empty page to discover it's done.
Set sane limits
Never trust the client's limit blindly — an unbounded page size is a one-line denial of service.
- Default to something small, around 20.
- Cap it with a hard maximum near 100; clamp anything larger.
- Reject non-numeric or negative values with a
400.
// clamp the limit — never let a client request a million rows
const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100)
Offset pagination: simple, until it isn't
Offset is the obvious approach — ?page=3&limit=20 skips (page - 1) × limit rows. It's fine for small, mostly-static datasets and it supports jumping to any page.
Two problems show up at scale. First, speed: to serve page 10,000 the database scans and throws away every earlier row, so deep pages get slow. Second, drift: if a row is inserted or deleted while a user pages, the offset shifts and they see a duplicated or skipped item at the page boundary.
Cursor pagination: stable and fast
Cursor (keyset) pagination fixes both. Instead of an offset, you remember the last row's sort key and fetch what comes after it with an indexed WHERE:
// cursor = the createdAt + id of the last row on the previous page
const rows = await db.orders.find({
createdAt: { $lt: cursor.createdAt },
})
.sort({ createdAt: -1, _id: -1 })
.limit(limit + 1) // fetch one extra to compute hasMore
const hasMore = rows.length > limit
const data = rows.slice(0, limit)
Cost stays flat at any depth because it's an index seek, not a scan, and inserts elsewhere in the list never shift a user's window. The trade-off: no "jump to page 47" — cursors only go next/previous, which is exactly what infinite scroll and feeds want anyway.
Whitelist sorting and filtering
Cursor pagination only works if the sort is stable and indexed, which makes a whitelist mandatory:
- Allow sorting only on indexed, named fields — map
sort=-createdAtto a known column, never reflect raw input into the query. - Break ties with a unique field (like
_id) so rows with identical timestamps have a deterministic order — otherwise the cursor can skip or repeat at ties. - Document the allowed filters and validate them the same way.
Get these four things right — a clear contract, clamped limits, cursor paging past the point offset hurts, and a sort whitelist — and your list endpoints stay fast and correct from the first row to the millionth.
FAQ
What's the difference between offset and cursor pagination?
Offset pagination skips a fixed number of rows (page × limit); it's simple but slows down on deep pages and can skip or repeat rows when data changes. Cursor pagination remembers a pointer to the last row seen and fetches what comes after it — stable and fast, but you can't jump to an arbitrary page number.
Why is offset pagination slow on large tables?
To return page 10,000 the database still has to scan and discard every earlier row before the offset. Cursor pagination uses an indexed WHERE clause instead, so cost stays flat no matter how deep you go.
Should I let clients sort by any field?
No. Only allow sorting on fields you've indexed and explicitly whitelisted. Sorting on an unindexed field forces a full scan, and reflecting arbitrary field names into a query is a common injection and performance footgun.

