The short answer
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.
Why the slice operator degrades
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.
The fix: cursor (keyset) pagination
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:
- Always keep an explicit
order(). Without it, GROQ does not guarantee deterministic ordering, so both offset and cursor pages can overlap or skip items between requests. - Handle ties. If many documents share the same
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.
What you give up
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.
Verifying the change
In Sanity Vision (or your client), confirm three things against known data:
- A sliced query returns exactly
min(pageSize, total - start) documents. - Two consecutive cursor pages share no
_id and leave no gap (the last item of page N sorts immediately before the first item of page N+1). - Insert a new matching document between requests and re-fetch: with cursors, previously fetched pages are unchanged; with offsets, you will observe drift.
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.