Offset pagination versus keyset pagination for deep-page queries in Knex.js
0 reputation · 25 Mar 2025, 00:23 UTC
0 reputation · 25 Mar 2025, 00:23 UTC
When building a REST endpoint that returns large result sets, the developer must decide how to paginate the data while keeping response times low and avoiding missing or duplicate rows under concurrent inserts.
Offset pagination is straightforward to implement with Knex.js using .limit(pageSize).offset(page * pageSize), but its cost grows with the page number because the database must skip earlier rows. Keyset pagination replaces the offset with a WHERE clause on a monotonic column (e.g., id > lastSeenId) and can use an index, yielding roughly constant query time.
The uncertainty lies in whether the table possesses a suitable unique, ordered column; if not, additional tie‑breaker columns are required to preserve determinism, which complicates the query.
Which pagination strategy should be chosen when the ordering column is not unique? How can tie‑breaker columns be added without making the query overly complex? Is there a way to retain the simplicity of offset pagination while achieving the performance benefits of keyset pagination in Knex.js?
32275 reputation · 25 Mar 2025, 05:39 UTC
When the ordering column is not unique, choose keyset pagination and append a unique column—almost always the primary key—as a final tie-breaker. Offset pagination cannot be made fast for deep pages; the database must scan and discard every skipped row, and concurrent inserts can shift rows across page boundaries, producing duplicates or omissions. The good news is that the tie-breaker complexity can be hidden inside one small helper, so your endpoint code stays nearly as simple as the offset version.
.limit(pageSize).offset(page * pageSize) has two problems. First, cost grows linearly with the offset: page 10,000 means the database reads and throws away hundreds of thousands of rows. Second, offset pagination is not stable under concurrent writes—a row inserted on page 1 pushes a row from page 1 onto page 2, so a client walking pages sees it twice (or never). Keyset pagination replaces the offset with a WHERE predicate on the sort key, which an index can seek directly, giving roughly constant query time and a stable position in the ordering.
Order by (sort_col, id) and compare the pair. In Knex.js, encapsulate it once:
function applyCursor(qb, cursor, pageSize) {
if (cursor) {
// Portable form; works on dialects without row-value comparison
qb.where(function () {
this.where('created_at', '>', cursor.sort)
.orWhere(function () {
this.where('created_at', cursor.sort)
.andWhere('id', '>', cursor.id);
});
});
}
return qb
.orderBy([{ column: 'created_at' }, { column: 'id' }])
.limit(pageSize);
}On PostgreSQL (and MySQL 8) you can shorten the predicate to a row-value comparison: .whereRaw('(created_at, id) > (?, ?)', [cursor.sort, cursor.id]). Verify your target dialect supports it before relying on the shorter form. For descending order, flip both the comparison operator (<) and the ORDER BY direction—if they disagree, pages will be wrong.
The endpoint then reads a cursor from the query string (e.g., the last row's created_at and id, encoded as opaque base64 JSON) and passes it to the helper. That is the entire added complexity over the offset version.
(sort_col, id) in the same order as the ORDER BY; without it, keyset queries may still scan.> comparisons because NULL never satisfies the predicate—exclude NULLs or add explicit NULL handling.EXPLAIN (or EXPLAIN ANALYZE) on the offset query at a deep offset (e.g., 100,000) and the keyset equivalent; compare rows scanned and execution time.One missing detail that changes the recommendation: whether your API consumers need random page access. If they do, a pure keyset design is not sufficient and you will need a hybrid approach.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.