Cursor-Based Pagination in Appwrite Databases: Stable Pages Without Skips or Duplicates
Offset pagination in Appwrite can skip or duplicate documents when writes happen between requests. Here's how to page stably with cursorAfter, explicit ordering, and sensible fallbacks.
05 Nov 2025, 18:40 UTC

The problem with offset paging
If you page through Appwrite documents with Query.offset(n), every page re-counts from the start of the result set at read time. When another client inserts or deletes a document between your requests, the window shifts: you silently re-read a document you already saw, or skip one entirely. For infinite scroll feeds and batch exports, that is a correctness bug, not a cosmetic one.
The fix is cursor pagination: anchor each page to the ID of the last document you actually received, using Query.cursorAfter() (or cursorBefore() for reverse traversal). Because the anchor is a concrete document rather than a position, concurrent writes elsewhere in the collection don't move your page boundary.
What you need first
- An Appwrite project (Cloud or self-hosted) with a database and a collection containing documents.
- An attribute suitable for deterministic sorting — typically a datetime attribute or the document ID (
$id). Cursor pagination requires an explicit, stable sort; withoutorderAsc/orderDesc, page boundaries are nondeterministic. - An SDK client initialized with a session, JWT, or API key whose identity has read permission on the documents. List results and totals only include documents the caller can read, so permission gaps look like missing data.
- Ideally, an index on your sort attribute. Without one, large collections will sort slowly on every page.
Note that SDK method names and signatures vary by language and major version, and newer Appwrite releases scope collections under databases (database ID + collection ID) rather than top-level collections. Confirm the exact call shape against your SDK's reference before coding.
Paging procedure
The pattern is the same in every SDK. This example uses the Web SDK; adapt the method names to yours.
import { Client, Databases, Query } from 'appwrite';
const client = new Client()
.setEndpoint('https://cloud.appwrite.io/v1') // your endpoint
.setProject('<PROJECT_ID>');
const databases = new Databases(client);
const PAGE_SIZE = 25;
// Page 1: explicit sort + limit, no cursor
let response = await databases.listDocuments(
'<DATABASE_ID>',
'<COLLECTION_ID>',
[
Query.orderDesc('createdAt'),
Query.limit(PAGE_SIZE),
]
);
let documents = response.documents;
// Later pages: anchor to the last document of the previous page
while (documents.length === PAGE_SIZE) {
const lastId = documents[documents.length - 1].$id;
response = await databases.listDocuments(
'<DATABASE_ID>',
'<COLLECTION_ID>',
[
Query.orderDesc('createdAt'), // identical sort
Query.limit(PAGE_SIZE), // identical limit
Query.cursorAfter(lastId),
]
);
documents = response.documents;
// process page...
}Three rules keep the cursor valid:
- Keep sort attribute, direction, and limit identical across pages. Changing any of them invalidates the cursor — the anchor document's position is defined relative to that ordering.
- Stop when a page returns fewer documents than the limit, or an empty list. That is your end-of-data signal.
- Pick a page size within your deployment's configured bounds. Appwrite enforces default and maximum list limits; don't hardcode an assumed universal maximum — check your console or environment settings.
For reverse traversal (a "load previous" button), use Query.cursorBefore(firstDocumentId) with the same ordering.
Expected checks
Each response is a list object exposing total and a documents array. Treat total as informational — it reflects only what the caller can read at request time and can change between pages.
To verify correctness before shipping:
- Seed a test collection with ~100 documents, page through with a fixed sort and
cursorAfter, and assert the union of all pages has no duplicate or missing$idvalues. - Run the same walk while a second process inserts and deletes rows, once with offset and once with cursor paging. The offset run should show skips or duplicates; the cursor run should not.
- Confirm that changing the sort direction mid-walk produces wrong boundaries — this proves your tests would catch an accidental sort change.
Recovery paths
The read is idempotent, so recovery is straightforward:
- Failed request: retry the same page with the same cursor. Nothing changed server-side.
- Cursor document deleted between pages: the anchor no longer exists. Fall back to offset pagination from your current count, or restart the walk from page one. For exports, restarting is usually simpler and safer.
- Permissions change mid-export: totals and page contents can shrink unexpectedly. Re-check access and totals rather than looping until an empty page that may never arrive in the form you expect.
Limitations
Cursor pagination gives you stable forward/backward traversal, but not random access — you cannot jump to "page 40" without walking there. If your UI needs numbered pages, offset paging (with its consistency caveats) or a materialized snapshot may fit better. Also remember that total is computed per request; don't use it to precompute page counts for a cursor walk.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.