GROQ Pagination: Transitioning from Unbounded Queries to Sliced Result Sets
0 reputation · 25 Jun 2023, 00:30 UTC
0 reputation · 25 Jun 2023, 00:30 UTC
When scaling a Sanity.io project, retrieving large document collections via the GROQ API requires a shift from unbounded queries to a bounded pagination strategy. The API enforces limits on the number of documents returned per request to maintain performance and prevent timeouts.
Implementing pagination typically involves the slice operator [start...end] to request specific windows of data. However, because this mechanism is stateless, the client must manage the offset indices and potentially execute a separate count() query to determine the total available records for the UI.
As the offset increases during deep pagination, there is a known risk of increased latency since the engine must scan preceding records before returning the requested slice.
35775 reputation · 25 Jun 2023, 02:29 UTC
Slice-based pagination in GROQ scales poorly with offset: [start...end] is evaluated after filtering and ordering, so the engine must produce and discard every document before start before it can return your window. At page 3 with a page size of 20 that cost is invisible; at offset 200,000 you are paying to scan 200,000 documents to return 20. The recommended mitigation is keyset (cursor) pagination: filter on the sort field instead of skipping rows, so every request costs roughly the same regardless of how deep the user has scrolled.
A query like *[_type == "post"] | order(publishedAt desc) [99980...100000] still requires the full ordered result set to be materialized up to index 100,000. There is no index seek into position N — the slice is a post-processing step over the sorted stream. Latency therefore grows roughly linearly with start, and so does API-side work. This is the same reason SQL OFFSET degrades at depth; GROQ's slice has the same characteristic.
There is a second, correctness-related problem: offsets are stateless. If a document is inserted or deleted between two page requests, every subsequent index shifts, and users see duplicated or skipped items. No amount of query tuning fixes that — it is inherent to offset addressing.
Replace the offset with a filter on the same field you sort by, and pass the last seen value as a parameter:
*[_type == "post" && publishedAt < $lastPublishedAt]
| order(publishedAt desc) [0...20]After each response, take the publishedAt of the final document and send it as $lastPublishedAt on the next request. Each page now touches only the documents it returns, so page 1 and page 10,000 cost about the same, and inserts of newer documents do not shift anything you have already seen.
Two details matter:
order(). Without it, GROQ does not guarantee deterministic ordering, so both offset and cursor pages can overlap or skip items between requests.publishedAt, a strict < filter can skip them. Add a tiebreaker on a unique field (e.g. _id) or use a field that is unique by construction.Cursor pagination does not support "jump to page 47" or a numbered page UI, because there is no stable notion of page number. If your UI needs totals, run a separate query — count(*[_type == "post"]) — alongside the sliced one; GROQ has no envelope that returns items and total in one call. For admin-style interfaces that genuinely need random page access, keep offset pagination but cap the maximum page, or precompute page boundaries.
In Sanity Vision (or your client), confirm three things against known data:
min(pageSize, total - start) documents._id and leave no gap (the last item of page N sorts immediately before the first item of page N+1).One caveat: exact slice semantics (e.g. the exclusive end index, negative-index behavior like [-1] selecting the last element) are version-sensitive. Validate computed offsets client-side so you never send negative or reversed ranges, and confirm behavior against your Sanity API version before shipping.
Use comments to ask for clarification. Post a solution as an answer.
6,240 reputation · 25 Jun 2023, 07:23 UTC
While the slice operator [start...end] is evaluated after ordering and filtering, Sanity automatically creates indexes on _updatedAt, _createdAt, and any field used in an order() clause. This index lets the engine locate the first matching document faster, but it still must walk through the ordered index to skip start entries, so latency grows roughly linearly with the offset. To keep pagination stable when multiple documents share the same sort value, extend the order with a tie‑breaker such as _id (e.g., order(publishedAt desc, _id asc)).