Offset pagination vs keyset cursors for a Node.js API that needs both deep pages and page jumps
0 reputation · 08 Oct 2021, 06:53 UTC
0 reputation · 08 Oct 2021, 06:53 UTC
I'm designing a read API in Node.js (using the pg driver against PostgreSQL) over a table expected to reach tens of millions of rows. Two documented pagination approaches seem to conflict on my requirements.
Offset pagination (LIMIT/OFFSET) gives clients random page access and works naturally with a page-number UI, but I understand the database scans and discards skipped rows, so latency grows with page depth. Keyset (cursor) pagination (WHERE id > :last ORDER BY id LIMIT n) keeps query cost roughly constant at any depth, but only supports sequential traversal and complicates showing total page counts.
My constraints: clients occasionally jump to arbitrary pages, deep pages must stay fast, and rows are inserted concurrently, so I also worry about duplicates or gaps between pages. I'd also like to bound memory on the Node.js side rather than buffering entire result sets.
Given these constraints, which approach should I standardize on — or is a hybrid (offset for shallow pages, keyset beyond a depth threshold) sound in practice? If I need a total count for the UI, is an estimated count acceptable alongside keyset? And does using a streaming cursor API like pg-cursor change the trade-off at all?
For a PostgreSQL table with tens of millions of rows, neither pure offset nor pure keyset pagination satisfies all your requirements. To support both fast deep pages and arbitrary page jumps, a hybrid approach is the most practical standard.
Implement offset pagination for shallow depths (e.g., the first 100 pages) and switch to keyset pagination for deeper traversal. This acknowledges that while users occasionally jump to arbitrary pages, they rarely jump to page 5,000. For the vast majority of users, the linear cost of OFFSET is negligible at shallow depths, but catastrophic at depth.
LIMIT n OFFSET m. Accept that concurrent inserts may cause rows to shift between pages.WHERE (sort_column, id) > ($1, $2) ORDER BY sort_column, id LIMIT n.next_cursor (a Base64 encoded string of the last row's sort values) to shift the client into keyset mode.Running SELECT COUNT(*) on millions of rows is expensive and will likely bottleneck your API. For the UI, an estimated count is typically acceptable. You can retrieve this from the PostgreSQL planner statistics:
SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'your_table_name';
Note that this estimate can drift between ANALYZE runs, but it provides the "approximate total" needed for page-number calculations without scanning the table.
The pg-cursor library bounds memory on the Node.js side by fetching rows in chunks, but it does not reduce the database's internal work. If you use OFFSET 1,000,000 with pg-cursor, PostgreSQL still scans and discards a million rows before streaming the result. Use pg-cursor for large data exports, but rely on keyset pagination for API request-response cycles to keep database latency low.
To ensure the implementation is performing as expected, run the following checks in a staging environment with production-scale data:
EXPLAIN (ANALYZE, BUFFERS) on both query types. The keyset query should show an Index Scan with constant cost regardless of the cursor value, while the offset query should show increasing Buffers and execution time as the offset grows.ORDER BY clause to avoid a costly Sort node in the query plan.Missing Detail: Does your business logic require a guaranteed exact count for the UI, or is a ±5% estimate acceptable?
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.