Range pagination vs keyset pagination for a large Supabase table under concurrent writes
0 reputation · 18 Mar 2023, 06:26 UTC
0 reputation · 18 Mar 2023, 06:26 UTC
I am designing pagination for a large table in Supabase (queried through supabase-js, so PostgREST semantics underneath). The dataset is big enough that pages will be fetched sequentially by users while other clients keep inserting and occasionally deleting rows.
The simple option is the range(from, to) helper (limit/offset style). My concern is that page boundaries are positional: if rows are inserted or deleted between requests, later pages could duplicate or skip items. I also understand deep offsets can get expensive because the database scans and discards preceding rows, though I have not measured at what offset this becomes a real problem.
The alternative is keyset pagination (WHERE id > last_seen_id ORDER BY id LIMIT n), which should be stable under concurrent writes but seems to lock me into sorting by one monotonic column. I would also like a total count for a pager UI, but I am unsure how costly an exact count is on a large filtered table.
My questions:
range() with a deterministic ORDER BY (unique tiebreaker) still risk duplicates or gaps when rows are inserted or deleted mid-pagination?Yes – range() (LIMIT/OFFSET) with a deterministic ORDER BY still suffers from duplicates or gaps when rows are inserted or deleted between page requests. Keyset pagination avoids this drift but requires an index on the sort column(s) plus a unique tiebreaker. An exact COUNT(*) on a large filtered table can be expensive; consider caching or approximating the count instead of querying it on every page.
range() with deterministic ordering still risk duplicates or gaps?Confirmed fact: PostgreSQL (and therefore PostgREST/Supabase) evaluates LIMIT/OFFSET by scanning rows in the specified order and skipping the first OFFSET rows. If a concurrent INSERT adds a row before the current offset, the scan will see an extra row and shift everything down, causing a duplicate on the next page. If a concurrent DELETE removes a row before the offset, the scan will skip fewer rows, causing a gap. Adding a unique tiebreaker (e.g., ORDER BY sort_col, id) only makes the order stable; it does not change the positional nature of OFFSET.
Likely explanation: The drift is purely a function of the changing physical position of rows relative to the offset value, not of the sort stability.
Confirmed fact: Keyset (cursor) pagination works efficiently when the WHERE clause can use an index: WHERE (sort_col, tiebreaker) > (last_seen_sort, last_seen_tiebreaker) ORDER BY sort_col, tiebreaker LIMIT n. This requires a B‑tree index on the sort column(s) and the tiebreaker (commonly the primary key).
Likely explanation: If the user can pick any column, you must either maintain an index on that column (or a covering composite index) or fall back to offset pagination for those ad‑hoc sorts. Creating many indexes may impact write performance, so evaluate the write‑read trade‑off.
Confirmed fact: SELECT COUNT(*) FROM table WHERE … triggers a full index or table scan in PostgreSQL unless the predicate matches a partial index that can be satisfied solely from the index. As the filtered set grows, latency grows linearly.
Likely explanation: For large tables under concurrent writes, the count query can add hundreds of milliseconds to seconds per request, which is often unacceptable for a pager UI. Approximate counts from pg_stat_user_tables or a periodically refreshed summary table are common mitigations.
created_at or id). Ensure a composite index exists on (sort_column, id).COUNT(*) with either:
pg_stat_user_tables.reltuples when exactness is not required.EXPLAIN ANALYZE on a representative high‑offset query to see the "Rows Removed by Filter" cost, and on the keyset query to confirm index usage.If you can confirm whether an index exists (or can be added) on the arbitrary sort columns users may choose, the recommendation between keyset and offset for those columns becomes clear. Without that detail, a safe default is to keep offset pagination for ad‑hoc sorts and keyset for the primary, indexed sort.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.