You scroll a feed, the next page loads, and three posts you already read appear again. Further down, an item you saw a minute ago has vanished. The query looks right and the index exists. The documents in the Firebase console are exactly what you expect.
Firestore pagination returns duplicate documents when your cursor describes a position in the result set rather than a specific document. Pass the last document snapshot to startAfterDocument, make the ordering total by appending document ID, and deduplicate what you render by document ID. That is the fix. The rest of this article is why each of those three parts is load-bearing, and the checks that prove it on your own data.
This article is about the transition between pages. If you are still deciding on the stack underneath it, tenant isolation and billing in a Flutter and Firebase SaaS stack covers the architectural questions that stay hard. If the list also has to survive a dead network, an offline-first Flutter architecture for field tools treats the local database as the source of truth instead.
Everything below uses a synthetic posts collection as a demonstration. The code is a reference implementation checked against the cloud_firestore 6.10.0 API surface, not a run I can show you the output of.
Why does Firestore pagination return duplicate documents?
Your result set is recomputed on every request, so a document inserted above the cursor slides the whole window down and the next page repeats rows you have seen.
Think about what an offset actually means here. Page one asks for the newest twenty posts. While the reader is looking at them, two posts get published. Page two asks for twenty posts starting at number twenty-one, but numbers twenty-one and twenty-two are now the last two documents page one already showed. They come back a second time. Delete two documents instead of inserting them and the arithmetic runs the other way: numbers twenty-one and twenty-two are documents nobody ever rendered, and the reader never learns they exist.
Same window. Different contents.
The Firestore documentation gives the correct shape in its pagination recipe: "Paginate queries by combining query cursors with the limit() method. For example, use the last document in a batch as the start of a cursor for the next batch." A cursor built that way names a document. The window after it no longer depends on how many documents sit above it.
A timestamp alone is not a sort key

Two documents written in the same second share a sort value, so a cursor carrying only that value cannot say which of them it meant.
This is the failure that survives the first fix and then shows up in production, because your test data was seeded by a loop fast enough to produce collisions and your development data was typed in by hand. Firestore's own documentation flags it next to the pagination example: a value cursor "will not have the desired effect if multiple cities have the exact same population value", and it recommends adding further ordering fields to the cursor to reduce the ambiguity.
The reason a document snapshot does not automatically save you is worth being precise about. When you pass a snapshot, its values become the cursor values — the snapshot is read for the fields you ordered by, not treated as an opaque pointer. So if createdAt is your only ordering field and two documents share it, the boundary is still ambiguous, snapshot or not. You need the ordering itself to be total.
| Sort key | What you get | What it costs |
|---|---|---|
createdAt only | Correct while every value is unique. Silent duplicates or skips the first time two documents collide. | Nothing to build, and a bug that only appears under write pressure. |
createdAt + FieldPath.documentId | A total order. One document always sits between two others, so "after this document" names exactly one boundary. | A composite index once you add a where filter. Firestore hands you the creation link in the error. |
| A server-assigned monotonic counter | A total order with human-readable positions. | A write-side bottleneck you now own, plus a migration for existing documents. |
Document ID is already there, already unique within the collection, and costs one extra orderBy. Use it. A query's default order is ascending by document ID anyway, so you are making an existing tie-break explicit rather than inventing one.
Build the cursor out of a document, not a position
Three things travel together: the ordered list of IDs you render, a set of IDs you have already accepted, and the last document snapshot of the most recent page. Change the filter and all three are discarded at once.
- Define the base query once, with the full ordering, and reuse it for every page. If the ordering in the cursor query and the ordering in the base query ever drift apart, the cursor silently points somewhere else.
- Ask for
pageSizedocuments. Keepsnapshot.docs.lastas the cursor for next time. - Accept each document only if its ID is new. The set is what makes a duplicate harmless instead of visible.
- Treat a short page as the end. Fewer documents than
pageSizemeans there is nothing after it. - Reset all of it together on a filter change.
class PagedFeed {
PagedFeed(this._db, {this.pageSize = 20});
final FirebaseFirestore _db;
final int pageSize;
final List<String> order = <String>[];
final Set<String> _accepted = <String>{};
DocumentSnapshot<Map<String, dynamic>>? _cursor;
bool _exhausted = false;
// One definition of the ordering. documentId makes it total, so
// startAfterDocument has exactly one boundary to land on.
Query<Map<String, dynamic>> _base() => _db
.collection('posts')
.orderBy('createdAt', descending: true)
.orderBy(FieldPath.documentId, descending: true);
Future<int> loadNextPage() async {
if (_exhausted) return 0;
var query = _base().limit(pageSize);
final cursor = _cursor;
if (cursor != null) {
query = _base().startAfterDocument(cursor).limit(pageSize);
}
final snapshot = await query.get();
if (snapshot.docs.isEmpty) {
_exhausted = true;
return 0;
}
_cursor = snapshot.docs.last;
var accepted = 0;
for (final doc in snapshot.docs) {
if (_accepted.add(doc.id)) {
order.add(doc.id);
accepted++;
}
}
if (snapshot.docs.length < pageSize) _exhausted = true;
return accepted;
}
void resetForNewFilter() {
order.clear();
_accepted.clear();
_cursor = null;
_exhausted = false;
}
}
limit(int), orderBy(Object field, {bool descending = false}), startAfterDocument and snapshots() are all on the Query class in cloud_firestore 6.10.0, and FieldPath.documentId is the sentinel that refers to a document's ID. The cursor itself is free: Firestore's billing documentation states there are no additional costs for using cursors, page tokens, and limits. An offset is the opposite. The same page states that a query including an offset is charged a read for each skipped document, and gives the arithmetic: an offset of 10 that returns one document costs 11 reads. That is the other reason to stop thinking in page numbers.
Keep one page live, keep the rest still

Attach a snapshot listener to the first page only and read older pages once. A listener's first snapshot re-emits every document it already holds, so one listener per page means every page re-announces itself whenever anything in it changes.
The behaviour is documented plainly: the first query snapshot contains added events for all existing documents matching the query. That is useful exactly once, when you populate the UI. Multiply it by eight pages of scrollback and you are paying for the same documents repeatedly, because listening to a query charges a read each time a document in the result set is added or updated.
So: the newest page is a stream, everything older is a one-shot get(). New posts arrive at the top, which is where a reader expects them, and the pages behind stay exactly as they were read.
StreamSubscription<QuerySnapshot<Map<String, dynamic>>>? _live;
void listenToFirstPage(void Function(QuerySnapshot<Map<String, dynamic>>) onSnapshot) {
_live?.cancel();
_live = _base().limit(pageSize).snapshots().listen(
onSnapshot,
onError: _reportListenFailure,
);
}
Future<void> dispose() => _live?.cancel() ?? Future<void>.value();
Cancel it when the screen goes away. Dart detaches a listener with listener.cancel(), and the same page notes that after an error "the listener will not receive any more events, and there is no need to detach your listener" — your error path reports and rebuilds rather than cleaning up.
What happens when a document is deleted or moves?
Removals arrive through docChanges as DocumentChangeType.removed, and that single type covers both a deleted document and one that no longer matches the query. The Dart API reference documents exactly that pair of meanings: a document that was "either deleted or no longer matches the query".
That single type carrying two meanings is the part people miss. A post that gets unpublished and a post whose createdAt was edited until it fell out of the window both reach you as a removal, and in both cases the right move is the same: drop the ID from the list and from the accepted set, together.
for (final change in snapshot.docChanges) {
if (change.type == DocumentChangeType.removed) {
_accepted.remove(change.doc.id);
order.remove(change.doc.id);
}
}
Dropping it from only one of the two is a trap worth naming. Leave the ID in _accepted and the document can never come back, because a later page that legitimately contains it will be deduplicated away.
A deletion does not invalidate a cursor you are still holding. Since the snapshot's field values are what serve as the cursor values, and your snapshot is already in memory, startAfterDocument still resolves to the same ordering position after the document itself is gone. The position survives the document.
Five checks that prove it
Run these against a synthetic collection, watching the rendered IDs rather than the row count. The row count is what hid the bug in the first place.
- Insert during a scroll. Load page one, write three documents, load page two. The new documents should appear in the live first page only, and page two should contain no ID already on screen.
- Delete something visible. Remove a document that is currently rendered. It disappears once, the surrounding IDs keep their order, and it is gone from both the list and the accepted set.
- Force a tie. Write two documents with an identical
createdAt. Both appear exactly once, including when the tie straddles a page boundary. SetpageSizeto 2 so the boundary is easy to land on. - Change the filter. Switch the query, then confirm the list, the set and the cursor were all cleared. A stale cursor from the previous filter is the quietest version of this bug.
- Scroll off the end. Pass the last document. Pagination stops instead of looping, and you do not keep firing empty queries — each one still costs you, since there is a minimum charge of one document read for each query even when it returns nothing.
If check three fails and the other four pass, your ordering is not total yet. If check one fails, look at whether the cursor query and the base query really do order by the same fields.
Where does this approach stop helping?
A composite index is required as soon as a where filter joins the ordering, and the first filtered query will fail until it exists. Firestore makes that recoverable rather than mysterious: the error message includes a direct link to create the missing index. Expect (createdAt desc, __name__ desc) plus your filter field.
Two limits are worth knowing before you commit to this shape. orderBy also filters for existence, so documents without the sort field are excluded from the result set — a post saved before you introduced createdAt is not sorted last, it is absent, and no amount of cursor work will surface it. And if offline persistence is on and a listener sits disconnected for more than 30 minutes, Firestore bills the reconnect as if you had issued a brand-new query, so a long-lived first-page listener on a backgrounded app is not free.
Actually, one more, and it is the honest limit of the whole approach: this gives you a consistent reading experience, not a consistent snapshot of the collection. A reader who scrolled to page four an hour ago is looking at an hour-old page four. That is usually what you want in a feed. It is not what you want in an audit log or an inventory count, and for those you want a different read model rather than a better cursor.
What FlutterFlow handles, and where Dart starts
FlutterFlow paginates a Firestore-backed ListView for you, and the documented behaviour has one consequence you should plan around before writing any custom code.
Turn on Enable Infinite Scroll under the ListView's Backend Query and FlutterFlow loads records in pages, with a page size that defaults to 25. The same documentation is explicit about what comes with it: "For Firestore queries, enabling infinite scroll also enables Listen For Changes. This updates documents already displayed when their data changes, but it does not add or remove list items when documents are created or deleted."
Read that twice if you are shipping a feed. Edits to loaded documents show up live; a brand-new post does not appear, and a deleted one does not leave. For a catalogue or a settings list that is reasonable. For anything where new records are the point, the built-in behaviour is not enough. The gap is exactly the work above: a total ordering with deduplication you control, plus a listener scoped to the newest page, written as custom Dart and called from the same screen.
Start with check three. Write two documents sharing a createdAt value, set the page size to 2, and watch the IDs cross the boundary. If that passes on your real collection, which part of your current pagination was actually the broken one?
