Keyset Pagination
Implement high-performance cursor-based pagination for massive result sets using pg-smart-search.
Keyset Pagination
Traditional OFFSET and LIMIT pagination becomes extremely slow on large datasets because the database must scan and count all previous rows. pg-smart-search v1.2+ introduces Keyset Pagination to solve this.
How Keyset Pagination Works
Instead of skipping rows, keyset pagination uses a cursor (typically the ID of the last retrieved item) to fetch the next batch.
-- Instead of this (slow, and non-deterministic without an ORDER BY)
SELECT * FROM products ORDER BY id OFFSET 10000 LIMIT 20;
-- Do this (fast)
SELECT * FROM products WHERE id > last_seen_id ORDER BY id LIMIT 20;With a B-tree index on id, this seeks in O(log N) to find the starting point instead of
scanning and discarding 10,000 rows first.
Cursor pagination only works on the id-ordered STANDARD/LITE path. ADVANCED, VECTOR,
and the fuzzy/layout/transliteration fallback steps all sort by relevance/similarity/
distance, not by id — WHERE id > cursor doesn't line up with that ordering and would
silently overlap or drop rows across pages. Passing cursor to a search that would hit
one of those paths throws UnsupportedCursorError instead. If you need cursor
pagination, use it against a STANDARD/LITE search where the query is expected to have
matches without needing fuzzy fallback; otherwise use page/limit.
Implementation
Pass the cursor parameter to the search method. There's no nextCursor field on the
result — take the id off the last row yourself:
// First page (page-based, since there's no cursor yet)
const firstPage = await engine.search({
query: "laptop",
limit: 20,
});
// Next page: cursor is the id of the last row you saw
const lastRow = firstPage.data[firstPage.data.length - 1] as { id: number };
const nextPage = await engine.search({
query: "laptop",
limit: 20,
cursor: lastRow.id,
});
if (!nextPage.pagination.hasNext) {
console.log("reached the end");
}Skipping the Total Count (skipTotalCount)
Calculating the exact total number of matching records (COUNT(*) OVER()) is expensive at
scale. The effective default is already "skip": unless you explicitly pass
skipTotalCount: false, pagination.total/totalPages stay undefined and hasNext is
derived instead from a cheap "fetch limit+1 rows, trim the probe row" check — no window
function at all. There's no per-page-number branching; this is purely caller-controlled on
every call.
// default: total/totalPages are undefined, hasNext still works correctly
const page = await engine.search({ query: "laptop", limit: 20 });
// opt in to an exact count (adds a COUNT(*) OVER() to the query)
const pageWithTotal = await engine.search({ query: "laptop", limit: 20, skipTotalCount: false });Keyset pagination requires a stable, uniquely-ordered column — the engine uses your
configured idColumn (default id) and does the cursor comparison as a plain `WHERE id
$cursor`. There's no cursor encoding/decoding step; whatever value you pass is bound directly as the parameter.