Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

For sequential pages in Cloudflare D1, keyset (cursor) pagination is usually a better fit than a deep OFFSET: it continues from the last row’s sort key instead of skipping an ever-larger prefix. But there is no universal D1 row-read count or guaranteed speedup. The actual work depends on the query plan, indexes, and data, so compare D1’s rows_read for representative requests. Keyset pagination also avoids a particular duplicate-row problem when inserts shift an OFFSET page boundary; it does not freeze results across requests.

Why deep OFFSET can read more rows than it returns

D1 uses SQLite query semantics and can be queried through Workers bindings, the REST API, or Wrangler. With LIMIT and OFFSET, the database must advance through the ordered results to get past the offset before returning the requested page. As the offset grows, that can mean more work even when the page size stays the same.

For example, a page of 20 rows at offset 20,000 still asks the query to move past the earlier ordered rows. The exact amount of work is not a fixed formula or D1-wide constant: filters, indexes, table contents, and the chosen plan all matter. D1 query metadata reports rows_read, which counts rows read during execution, including index rows; it can exceed the number returned.

Choose pagination based on how people navigate

Consideration OFFSET pagination Keyset pagination
Navigation Supports jumping to a numbered page. Continues from a cursor; arbitrary page-number jumps are not its natural use.
Work at increasing depth May need to advance past earlier rows; measure rows_read on the actual query. Can seek from the last sort key when predicate, ordering, and index align; verify the plan and metadata.
Ordering Needs deterministic ordering to make page contents meaningful. Needs deterministic ordering and a unique tie-breaker if the main sort value repeats.
Inserts between requests An insert before a page boundary can shift positions and repeat or skip rows across pages. Avoids that positional shift; later requests may still include new rows after the cursor.
Best fit Interfaces where users need direct page-number navigation. Feeds, “load more” flows, or sequential traversal.

Replace OFFSET with a cursor on a stable ordering key

Ascending unique key

If id is unique and increasing, this OFFSET query is straightforward but may do more work at deep page positions:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
SELECT id, created_at, title
FROM posts
ORDER BY id
LIMIT ? OFFSET ?;

For sequential traversal, pass the last id returned on the prior request:

SELECT id, created_at, title
FROM posts
WHERE id > ?
ORDER BY id
LIMIT ?;

The cursor is a boundary, not a page number. On each request, use the final key from the previous response, then request the next batch.

Descending or non-unique sort keys

For descending order by a unique id, reverse both the comparison and ordering, using id < ? ORDER BY id DESC. If the sort key can repeat, add a unique tie-breaker to both the ordering and cursor. For example, a traversal ordered by created_at, id needs both values in its cursor and a matching lexicographic predicate, conceptually (created_at, id) > (?, ?) for ascending order. Confirm that the exact SQL syntax and index plan are supported for your query. Avoid nullable cursor columns unless the query explicitly handles null ordering.

A suitable index should align with the filter and ordering used by the query. Without it, a keyset predicate alone does not guarantee bounded work. Cloudflare’s D1 indexing guidance recommends indexes to reduce rows read, while noting that indexes can add write work when indexed columns are updated.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

What inserts between page requests do

OFFSET can repeat a row after an earlier insert

Suppose the first request uses ORDER BY id ASC LIMIT 20 OFFSET 0 and returns IDs 1 through 20. If a row with ID 0 is inserted before the next request, LIMIT 20 OFFSET 20 now starts at ID 20. That row can appear on both pages because the insertion shifted the positional window.

Keyset continues past a boundary, not through a frozen snapshot

If the next query is WHERE id > 20 ORDER BY id LIMIT 20, an inserted row with an ID below or equal to 20 is not included in that continuation. A newly inserted row with a larger ID can appear on a later page if the cursor has not passed it. This is often appropriate for a live feed, but it is different from an export that must represent one fixed set of rows. Updates to sort keys and deletions can also alter which rows appear during traversal.

A cursor provides continuation from a boundary; it does not by itself establish a snapshot spanning separate HTTP requests. For a stable export, one application-level option is to capture a maximum ID at the start and apply id <= cutoff on every page, provided that the key and application’s write behavior make that boundary suitable. Any transaction or snapshot strategy should be verified against the consistency guarantees of the specific design.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Measure rows_read and inspect the query plan

Do not infer D1 cost from the page size alone. The D1 API reference defines meta.rows_read as rows read during SQL execution, including indexes, and notes that not all rows read are necessarily returned. Metadata also exposes SQL duration excluding network time.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  1. Run representative requests. Use the same filters and selected columns for the OFFSET baseline and keyset candidate. Test shallow and deep pages, and realistic cursor values.
  2. Record returned rows and metadata. Capture page depth or cursor, returned row count, rows_read, and SQL duration for each request. Do not treat one run as a universal result.
  3. Inspect the plan. Use EXPLAIN QUERY PLAN with the query to investigate whether SQLite reports a full SCAN or a SEARCH ... USING INDEX. Check that the index matches the predicate and ordering.
  4. Evaluate the workload trade-off. An index may reduce read work but add write work when indexed values change. Compare both against the application’s actual read and write patterns.
Query Page depth or cursor Plan Returned rows D1 rows_read SQL duration
OFFSET baseline Record actual offset Record actual plan Measure Record metadata Record metadata
Keyset candidate Record actual cursor Record actual plan Measure Record metadata Record metadata

Keep D1 limits in perspective

Cloudflare’s D1 Limits page, last updated April 21, 2026, lists a maximum SQL query duration of 30 seconds and says each individual D1 database is inherently single-threaded, processing queries one at a time. It also lists query subrequest limits of 1,000 per Worker invocation on Workers Paid and 50 on Free as of that update. These are platform limits, not an OFFSET threshold, page-size cap, or promise that a pagination query will finish within a particular time.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.