Core concept
Pagination
Every list and search is bounded. This page explains why, how cursors work, and how to walk a result set without loading it all into memory.
There is no “fetch everything” mode
This is a deliberate constraint, not a missing feature.
A single unbounded read against a large app would have to load the whole dataset into memory — the server's, and then yours. On a phone that is fatal; on a server it is a slow outage waiting for the account that finally grows large enough.
| Value | Notes | |
|---|---|---|
| Default limit | 20 | Applied when you omit limit entirely. |
| Maximum limit | 100 | A larger value is clamped, not rejected — the SDKs clamp client-side too. |
| Order | Newest first | Keyset on (createdAt, id), which is what makes the cursor stable. |
The response envelope
Every list and search returns the same shape.
{
"success": true,
"totalFetched": 20,
"hasMore": true,
"nextCursor": "MjAyNi0wOC0xMlQxMDowNDoxMS4yODRafGNtc3E4…",
"data": [ /* 20 records */ ]
}totalFetched is not a total count
It is how many records are in this page, not how many exist. List endpoints call it totalFetched and search endpoints call it totalItems; the SDKs normalise both to one field. There is no cheap count of everything, because counting would mean scanning what pagination exists to avoid.
Cursors, not page numbers
A cursor encodes a position in the ordering, not an offset.
The cursor is an opaque encoding of the last row's createdAt and id. The next query asks for rows ordered before that point, which is a range scan on an index — the cost is the same whether you are on page 2 or page 2,000.
Why not OFFSET?
OFFSET 40000 makes the database walk and discard forty thousand rows before returning anything, so deep pages get steadily slower. It also skips and repeats rows when records are inserted while a reader is paging. Keyset pagination has neither problem.
The cost is that you cannot jump to “page 7”. In practice that is what infinite scroll and “load more” already assume.
Reading one page
1const page = await authix.getUserData({
2 userId,
3 category: "notes",
4 limit: 50,
5});
6
7page.data; // the records
8page.hasMore; // is there another page?
9page.nextCursor; // pass back as `cursor` to get itWalking every page
Follow the cursor yourself, or let the SDK do it.
1let cursor;
2const all = [];
3
4do {
5 const page = await authix.getUserData({ userId, limit: 100, cursor });
6 all.push(...page.data);
7 cursor = page.hasMore ? page.nextCursor : undefined;
8} while (cursor);The iter_* and iterate* helpers exist because hand-rolling the loop has one easy mistake in it — see below. In Dart they return a Stream, so a list can render its first page while the rest arrives.
The loop that never ends
The one bug worth knowing about in advance.
Guard on the cursor, not just hasMore
If a response ever claims hasMore: true but returns no nextCursor, a loop that only checks hasMore will refetch page one forever — pulling the same rows, indefinitely.
Stop when either is missing. Every SDK's built-in traversal already does, which is the main reason to prefer it over your own loop.
Practical advice
- Use a filter before you use a bigger page. A
keyssearch that returns 12 rows beats paging 4,000 to find them. - Bound every traversal you do not control.
max_pages/maxPagesturns an unbounded sweep into a known cost. - Append, do not replace, on “load more”. A common UI bug is a second page that wipes the first.
- Do not store cursors long-term. They encode a position in an ordering; treat them as valid for the current traversal, not as bookmarks.
- Cache on your side for full sweeps. Re-reading your whole dataset on every launch burns request quota for data that rarely changes.
Related