Introduction
If you are reading this, you have likely hit a wall with your Flutter app’s data loading. You have a list of items—maybe a social feed, a product catalog, or a chat history—and you are currently fetching all of them at once from Firebase. At first, it worked fine. But as your app grew, you started noticing UI lag, memory spikes, and worst of all, a terrifying spike in your Firebase billing dashboard.
You need flutter firestore pagination. Specifically, you need to implement an infinite scroll that fetches a small batch of documents, waits for the user to scroll down, and then seamlessly loads the next batch.
I have been building production Flutter apps for over five years, and I can tell you that every single developer faces this exact hurdle. It is a rite of passage. In 2026, building scalable apps means protecting your users’ data plans and your own wallet. The good news? The solution is highly standardized once you understand the underlying mechanics.
This article is written specifically for Flutter developers who are actively stuck trying to make a firestore load more flutter feature work. We are going to bypass the generic documentation and dive deep into the startAfterDocument cursor technique. By the end of this guide, you will have a robust, production-ready infinite list that doesn’t duplicate data, doesn’t trigger race conditions, and scales beautifully.
What the Problem Is
When you first connect a Flutter ListView to Firestore, the easiest approach is to use a StreamBuilder or FutureBuilder that simply calls FirebaseFirestore.instance.collection('posts').get().
As developers, we experience the problem when the collection grows from 10 documents to 10,000 documents. Suddenly, navigating to that screen freezes the app for three seconds. The console throws out memory warnings. If you try to fix this by simply adding .limit(10) to your query, you only ever see the first 10 items. When the user scrolls to the bottom, nothing happens.

If you attempt to hack a solution by increasing the limit dynamically (e.g., fetching 10, then fetching 20, then 30), you are falling into a massive trap. Fetching 20 documents re-reads the first 10. Fetching 30 re-reads the first 20. This exponential read pattern is exactly how developers accidentally rack up thousands of dollars in Firestore charges overnight.
Why This Happens (Real Explanation)
To understand why pagination in Firestore feels tricky, you have to understand how NoSQL databases scale. In a traditional SQL database, you might use an OFFSET command (e.g., LIMIT 10 OFFSET 20).
Firestore does not support true, cost-free offsets. If you use Firestore’s built-in offset method, the database engine still has to read all the skipped documents internally to find the starting point. You are billed for every single document it skips over.
Because of this, Firestore relies on cursors. A cursor is literally a pointer to a specific document. Instead of telling Firestore “skip 20 items,” you must tell Firestore: “Here is the exact document I looked at last. Give me the next 10 documents that come after this one.”

This is where the startAfterDocument method comes in. It requires you to hold onto the DocumentSnapshot of the very last item in your current list. When the user triggers a flutter infinite scroll firestore event, you pass that snapshot back to Firestore so it knows exactly where to resume querying. No skipped reads, no wasted money.
When You Usually See This Issue
You will encounter the need for the startAfterDocument cursor technique in almost any production-grade application. Realistic scenarios include:
- Social Media Feeds: Loading user posts, images, and videos sequentially as the user scrolls.
- E-Commerce Catalogs: Displaying hundreds of products in a grid where loading all images at once would crash the device.
- Chat Applications: Loading older messages when a user scrolls up in a conversation history.
- Activity Logs: Displaying transaction histories or audit trails for enterprise clients.
If you are building an app meant for real users, you cannot escape this. A scalable app demands efficient data fetching from day one.
Quick Fix Summary (Decision Shortcut)
If you are in a rush and just need to know the core mechanics to fix your broken pagination, here is the architectural summary:
- Step 1: Create a state variable to hold the last
DocumentSnapshot(e.g.,DocumentSnapshot? lastDocument;). - Step 2: On your initial fetch, save the last document from the returned query snapshot into your state variable.
- Step 3: Attach a
ScrollControllerto yourListView. Whenposition.pixels == position.maxScrollExtent, trigger the next fetch. - Step 4: In the next fetch, use
.startAfterDocument(lastDocument!).limit(10). Append the new results to your existing list and update thelastDocument.
Step-by-Step Solution (Core Section)
Let’s build a rock-solid, production-ready infinite scrolling list. We will use a standard StatefulWidget for clarity, though in a real-world scenario, you would likely move this logic into a state management solution. If you are debating which state management to use for this, I recommend reading up on Flutter Cubit vs BLoC: When to Use Each for Scalable Apps, as BLoC handles these pagination events beautifully.
1. Defining the State Variables
First, we need to track our data, our loading states, and our Firestore cursor.
class PaginatedFeedScreen extends StatefulWidget {
@override
_PaginatedFeedScreenState createState() => _PaginatedFeedScreenState();
}
class _PaginatedFeedScreenState extends State {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final ScrollController _scrollController = ScrollController();
List _items = [];
bool _isLoading = false; // Tracks initial load
bool _isFetchingMore = false; // Tracks pagination load
bool _hasMoreData = true; // Flag to stop fetching when end is reached
DocumentSnapshot? _lastDocument; // The critical cursor
final int _documentLimit = 10;
@override
void initState() {
super.initState();
_fetchInitialData();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Why this works: We separate _isLoading (for the very first load) from _isFetchingMore (for background loading at the bottom). The _hasMoreData flag is crucial; without it, your app will endlessly ping Firestore for data that doesn’t exist.
2. The Initial Fetch
When the screen loads, we need to grab the first batch of documents.
Future _fetchInitialData() async {
setState(() {
_isLoading = true;
});
try {
// ⚠️ Always order your documents when paginating!
QuerySnapshot querySnapshot = await _firestore
.collection('posts')
.orderBy('createdAt', descending: true)
.limit(_documentLimit)
.get();
if (querySnapshot.docs.isNotEmpty) {
_items = querySnapshot.docs;
_lastDocument = querySnapshot.docs.last;
if (querySnapshot.docs.length < _documentLimit) {
_hasMoreData = false;
}
} else {
_hasMoreData = false;
}
} catch (e) {
print("Error fetching initial data: $e");
} finally {
setState(() {
_isLoading = false;
});
}
}
Why this works: We use .orderBy() because cursors rely on a predictable order. We save the querySnapshot.docs.last into our _lastDocument variable. If the database returns fewer items than our limit (e.g., we asked for 10, but got 7), we instantly know we have reached the end of the collection, so we set _hasMoreData = false.
3. The Paginated Fetch (startAfterDocument)
This is the heart of the flutter firestore pagination logic. This function is called when the user scrolls to the bottom.
Future _fetchMoreData() async {
// Prevent race conditions: don't fetch if already fetching or if no more data
if (_isFetchingMore || !_hasMoreData) return;
setState(() {
_isFetchingMore = true;
});
try {
QuerySnapshot querySnapshot = await _firestore
.collection('posts')
.orderBy('createdAt', descending: true)
.startAfterDocument(_lastDocument!) // The magic happens here
.limit(_documentLimit)
.get();
if (querySnapshot.docs.isNotEmpty) {
_items.addAll(querySnapshot.docs);
_lastDocument = querySnapshot.docs.last;
if (querySnapshot.docs.length < _documentLimit) {
_hasMoreData = false;
}
} else {
_hasMoreData = false;
}
} catch (e) {
print("Error fetching more data: $e");
} finally {
setState(() {
_isFetchingMore = false;
});
}
}
Why this works: We pass our saved _lastDocument into .startAfterDocument(). Firestore looks at that specific document, finds its place in the index based on createdAt, and returns the next 10 items. We then use _items.addAll() to append the new documents to our existing list, keeping the UI intact.
4. The Scroll Listener
We need to trigger _fetchMoreData() at the right time.
void _onScroll() {
// Check if we are at the bottom of the list
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_fetchMoreData();
}
}
Pro Tip: Notice the - 200. Instead of waiting until the user hits the absolute bottom (position.pixels == maxScrollExtent), we trigger the fetch 200 pixels before they hit the bottom. This creates a much smoother experience, as the network request fires while they are still scrolling, often loading the data before they even see the loading indicator.
5. Building the UI
Finally, we render the list. We need to account for the initial loading state, the empty state, and the pagination loading indicator at the bottom.
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (_items.isEmpty) {
return const Scaffold(
body: Center(child: Text("No posts found.")),
);
}
return Scaffold(
appBar: AppBar(title: const Text("Infinite Feed")),
body: ListView.builder(
controller: _scrollController,
itemCount: _items.length + (_hasMoreData ? 1 : 0),
itemBuilder: (context, index) {
// If we reach the end of the current items, show the loader
if (index == _items.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
}
var data = _items[index].data() as Map;
return ListTile(
title: Text(data['title'] ?? 'No Title'),
subtitle: Text(data['createdAt'].toDate().toString()),
);
},
),
);
}
}

When to use this solution: Use this approach for any standard vertical or horizontal list where data is historical or sequential (feeds, logs, messages). If your layout uses complex scrolling mechanics, you might want to integrate this with Slivers. For help with that, check out our guide on Mastering Flutter SliverAppBar: Fix Layout Errors & Smooth Headers.
When NOT to use this solution: Do not use this if your data changes rapidly in real-time and users need to see updates instantly (like a live stock ticker). startAfterDocument works best with one-time .get() calls. Doing infinite scroll with real-time snapshots() streams is notoriously difficult because adding a new document at the top shifts the entire list, breaking the cursor.
Common Mistakes Developers Make
Even with the code above, developers often trip over a few specific NoSQL hurdles. Here are the most common mistakes I see in production environments:
- Forgetting the
isFetchingMoreLock (Race Conditions): If you don't lock your fetch function, scrolling fast will trigger the listener multiple times before the first network request finishes. You will end up fetching the same "next 10" documents three times, resulting in duplicate items in your UI. - Ordering by Non-Unique Fields: If you order your query by a field like
price(e.g.,.orderBy('price')), and you have 15 items that all cost $10.00, Firestore's cursor gets confused. It doesn't know which $10.00 item you are pointing at. The Fix: Always add a secondary, unique tie-breaker to your query:.orderBy('price').orderBy('id'). - Using
offset()instead of cursors: As mentioned earlier, usingoffset()will drain your Firebase quota. According to the official Firebase cursor documentation, offsets scale linearly in cost, while cursors offer constant time and cost performance. - Not separating concerns: Keeping this logic in the UI layer makes your app hard to test. As your app scales, you should move this fetching logic into a dedicated repository. Read our guide on Mastering the Flutter Repository Pattern for Clean Architecture to see how to abstract Firebase calls properly.
Warnings and Practical Tips
⚠️ Warning: Firestore Indexes are Mandatory
If you use multiple .where() clauses alongside your .orderBy() for pagination, Firestore will throw a specific error in your debug console containing a URL. You must click that URL to automatically generate a composite index in your Firebase console. Pagination will fail completely until that index is built.
💡 Practical Tip: Debounce the Scroll Listener
While the boolean lock (_isFetchingMore) works well, adding a slight debounce to your scroll listener can improve performance by preventing the Flutter engine from evaluating the scroll math on every single pixel movement.
💡 Practical Tip: Use startAfter() for pure values
While startAfterDocument() is the safest and most common method, you can also use startAfter([value]) if you know the exact value of the ordered field. However, passing the full DocumentSnapshot is highly recommended because it handles tie-breakers automatically.
Edge Cases and Limitations
Handling Deleted Documents
What happens if a user deletes a post from the middle of your paginated list? If you simply remove it from your local _items list, the UI updates fine. However, your cursor (_lastDocument) remains the same. This is generally safe, but if the very last document in your list is deleted on the server before you trigger the next pagination, Firestore might return slightly unexpected results depending on your index. Usually, passing a snapshot of a deleted document to startAfterDocument still works because Firestore uses the snapshot's index data (like its timestamp), not its current existence, to find the starting point.
Real-time Pagination (Streams)
Trying to combine StreamBuilder with pagination is an edge case that causes immense frustration. If you listen to a stream of 10 items, and then increase the limit to 20, the stream re-emits all 20 items. This causes the entire UI to flash and rebuild. If you absolutely need real-time updates on a paginated list, the industry standard is to fetch the list using Futures (as shown above), and only use Streams to listen to updates on individual documents, or use a complex state management architecture to merge stream data into a local list.
What Happens If You Ignore This Problem
Ignoring pagination is not just a minor technical debt; it is a critical business risk. If you launch an app without a proper firestore load more flutter implementation, here is what will happen:
- UX Degradation: As the collection grows, the initial load time will increase from milliseconds to seconds. Users will stare at a blank screen and abandon your app.
- Device Resource Exhaustion: Loading 5,000 documents with high-resolution image URLs into memory will cause Out of Memory (OOM) crashes, especially on older Android and iOS devices.
- Financial Penalty: Firestore charges $0.036 per 100,000 document reads (as of standard pricing). If 1,000 users open your app daily, and your app fetches a collection of 5,000 unpaginated posts every time they open it, you are generating 5,000,000 reads a day. You will burn through the free tier instantly and start racking up hundreds of dollars in unnecessary bills.
FAQ Section
How do I paginate with a StreamBuilder?
It is highly recommended not to use StreamBuilder for infinite scrolling. Increasing the limit of a stream causes the entire stream to re-evaluate, costing you duplicate reads and causing UI flicker. Use the Future-based approach with startAfterDocument as detailed in this guide.
Why is my list duplicating items when I scroll?
You are experiencing a race condition. Your scroll listener is firing multiple times before the first batch of data returns. Ensure you have an _isFetchingMore boolean flag that immediately returns false if a fetch is already in progress.
Can I use the `offset` method instead?
Technically yes, but practically no. Firestore's offset method still charges you for the documents it skips. If you offset by 1,000, you are billed for 1,000 reads just to get to document 1,001. Always use cursors (startAfterDocument).
How do I detect when the user reaches the absolute end of the database?
Compare the number of documents returned in your query to your requested limit. If you request limit(10) and the query returns 7 documents (or 0), you know there is no more data. Set a boolean flag (_hasMoreData = false) to stop future queries.
Final Takeaway & Conclusion
Mastering flutter firestore pagination is a massive step forward in your journey as a Flutter developer. It shifts your mindset from simply making things work on a simulator to building robust, cost-effective architectures that can handle thousands of real users.
Here is your actionable checklist before you deploy your infinite list:
- Verify you are using
.startAfterDocument()and passing the last snapshot. - Ensure you have a boolean lock (
_isFetchingMore) to prevent rapid-fire scroll requests. - Check that your query includes an
.orderBy()clause with a unique tie-breaker if necessary. - Confirm that your
ListView.buildercorrectly displays a loading indicator at the bottom based on your state.
Pagination can feel intimidating the first time you wire up the ScrollController math, but once you implement this pattern, you will use it in every single app you build. Take the code provided, integrate it into your project, and watch your Firebase read counts drop dramatically. You've got this.






