Cursor Pagination in Fauna: Trading Page Numbers for Per-Page Consistency
Fauna replaces offset paging with an opaque, index-backed cursor: each page is its own ACID transaction, page numbers disappear, and cost stays flat at any depth.
26 May 2026, 18:44 UTC

Your order history endpoint works fine with a few hundred rows. Then a customer with 40,000 orders asks why page 900 times out, and why the same order shows up twice when someone places a new one mid-scroll. This is the classic failure of offset pagination, and it's the problem Fauna's pagination model is designed to sidestep entirely — by not giving you offsets at all.
The thesis: Fauna turns pagination into a transactional, index-backed primitive. You give up numbered pages and arbitrary jumps; in return you get per-page ACID consistency, stable performance at any depth, and predictable cost. That's a good trade for feeds and infinite scroll, and a bad one for admin grids with a "go to page 7" box.
The page object: Fauna's only pagination primitive
In FQL v10, you paginate by calling paginate() on a set. The result is a page object with a data array and an opaque after cursor (plus before for walking backwards). You hand the cursor back to Fauna to get the next page. The cursor is opaque by design: clients must treat it as an uninterpreted token, not something to parse or construct.
If you're on the older FQL v4, the equivalent is Paginate() with the same after/before concept but different syntax and driver APIs. Don't mix the two; the snippets below are all FQL v10.
Worked example: an order history feed
Say you have an Order collection and want each customer's orders, newest activity included, twenty at a time. Ordering in Fauna comes from the index definition — terms filter, values sort — not from an SQL-style ORDER BY over arbitrary fields. So you define an index whose term is the customer reference and whose values include a timestamp:
// FQL v10
collection Order {
index byCustomer {
terms [.customer]
values [.createdAt, .id]
}
}The first fetch, run through the Fauna driver or the dashboard shell (needs a key with read access to the collection):
// FQL v10 — page 1
Order.byCustomer(customerRef).paginate({ size: 20 })The response contains up to 20 orders and, if more exist, an after token. Your API returns that token to the client, which sends it back for page 2:
// FQL v10 — page 2, using the cursor from page 1
Set.paginate(afterCursor)The walk ends when a response arrives with no after. For newest-first ordering, put the timestamp in the index values and check the current docs for how descending order is expressed in your FQL version — the syntax differs between v4 and v10, so verify rather than guess.
Each page is a transaction; the walk is not a snapshot
Every page fetch is a single strictly serializable ACID transaction, so one page is internally consistent. But a multi-page walk is many separate transactions. Writes committed between fetches can shift later pages: an order inserted while your user scrolls can push an item onto a page you've already fetched (duplicate) or off a page you haven't (skip). This is standard cursor-pagination behavior, not a Fauna quirk, but it's worth stating explicitly in your API docs so client developers de-duplicate by ID if it matters.
The trade-off: no page numbers
There is no numeric OFFSET. You cannot jump to page 7. Simulating it by fetching and discarding items reads rows you'll throw away and burns read operations doing it. The practical engineering decision is to design the UX around next/previous controls or infinite scroll, which is what cursor pagination models naturally.
Cost shape is the other half of the decision: every page is a billed transaction whose read ops scale with page size. Larger pages amortize per-request overhead at the cost of latency and payload size. Default and maximum page sizes differ between FQL versions, so treat any specific number as version-sensitive and check the docs for your target.
How to verify the behavior yourself
Before relying on this in production, create the collection and index, insert a few dozen sample rows, and walk two pages using the returned cursor. Then, between fetches, insert and delete rows and watch duplicates or skips appear — seeing it once beats trusting any blog post, including this one. Also confirm paginate() defaults, cursor guarantees, and how index changes affect outstanding cursors in the current documentation.
One more caveat: Fauna's product and company direction saw announced changes in 2025 that I can't confirm from here. Verify current service availability, pricing, and supported drivers before committing to a design or publishing anything based on this.
The actionable part
If you build on this: return the after cursor verbatim in your API responses, document that clients must treat it as opaque, design for next/previous rather than numbered pages, and pick a page size that balances per-request overhead against latency. You give up page jumps; you get consistent pages and flat cost at any depth. For feeds, that's the right deal.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.