pg-smart-search
Guides

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.