Implementing Efficient Chat Pagination in Flutter with Firebase Firestore
Problem Statement and Requirements
While developing a chat application, we faced a dilemma regarding message loading strategy:
100 message limit: Fast initial loading, but poor user experience when checking older conversations
1000+ messages loaded: Loading all messages at once increases initial loading time, network costs, and Firestore read costs
Optimal solution: Load only the latest 100 messages initially, and progressively load older messages when users scroll up using pagination
This document provides a detailed explanation of implementing efficient chat message pagination using Firebase Firestore.
Technology Stack and Architecture
Backend: Firebase Firestore
State Management: GetX
Architecture Pattern: Repository Pattern + Controller Pattern
Database Structure
In Firestore, chat messages are stored in the following structure:
circles/{circleId}/chatRooms/{chatRoomId}/messages/{messageId}
Each message document contains the following fields:
createdAt: Timestamp (message creation time)senderId: String (sender ID)senderName: String (sender name)message: String (message content)
Implementation Strategy
1. Initial Loading: Latest 100 Messages
We use Firestore's orderBy and limit queries to fetch only the latest 100 messages.
2. Pagination: Cursor-Based Pagination
Firestore uses cursor-based pagination rather than offset-based pagination. This is more performant and ensures data consistency in real-time databases.
Principle of Cursor-Based Pagination:
Query the next page based on the field value (e.g.,
createdAt) of the last loaded documentCombine
whereclause withorderByto filter messages before a specific point in time
Code Implementation
1. Repository Layer: Firestore Query Optimization
Previous Code (Issues)
Stream<List<ChatMessage>> getChatMessagesStream(String circleId, String chatRoomId) {
return _getMessagesRef(circleId, chatRoomId)
.orderBy('createdAt', descending: true)
.limit(1000) // Loading all messages at once
.snapshots()
.map((snapshot) {
final messages = snapshot.docs
.map((doc) => ChatMessage.fromSnapshot(doc))
.toList();
messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
return messages;
});
}
Issues:
Initial loading requires reading 1000 documents
Increased Firestore read costs (1000 reads per load)
Wasted network bandwidth
Increased initial loading time
Improved Code: Optimized Initial Loading
Stream<List<ChatMessage>> getChatMessagesStream(String circleId, String chatRoomId) {
try {
// Fetch messages from subcollection - get latest messages first, then reverse sort
return _getMessagesRef(circleId, chatRoomId)
.orderBy('createdAt', descending: true) // Latest messages first
.limit(100) // Load only the latest 100 messages
.snapshots()
.map((snapshot) {
final messages = snapshot.docs
.map((doc) => ChatMessage.fromSnapshot(doc))
.toList();
// Reverse sort so latest messages come first (oldest → newest)
messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
return messages;
});
} catch (e) {
print('Error in getChatMessagesStream: $e');
return Stream.value([]);
}
}
Improvements:
limit(100)reduces initial loading costs (100 reads per load)Maintains
snapshots()stream for real-time updatesClient-side chronological sorting (for UI display)
New Method: Pagination for Older Messages
/// Load older messages (pagination)
Future<List<ChatMessage>> loadOlderMessages({
required String circleId,
required String chatRoomId,
required DateTime beforeDate,
int limit = 100,
}) async {
try {
// Cursor-based pagination: query only messages before beforeDate
final query = _getMessagesRef(circleId, chatRoomId)
.orderBy('createdAt', descending: true)
.where('createdAt', isLessThan: Timestamp.fromDate(beforeDate))
.limit(limit);
final snapshot = await query.get();
final messages = snapshot.docs
.map((doc) => ChatMessage.fromSnapshot(doc))
.toList();
// Sort in ascending order (oldest first for UI display)
messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
return messages;
} catch (e) {
print('Error loading older messages: $e');
return [];
}
}
Key Points:
Cursor-based pagination:
where('createdAt', isLessThan: Timestamp.fromDate(beforeDate))beforeDateis thecreatedAtvalue of the oldest message currently loadedOnly queries messages before this value to prevent duplicates
Query optimization: Using
orderByandwheretogetherFirestore may require a composite index
Index on
createdAtfield is automatically created
Consistent sorting: Client-side chronological sorting
Firestore fetches newest first with
descending: trueRe-sorted in ascending order for UI display
2. Controller Layer: State Management and Business Logic
Adding State Variables
class ChatController extends GetxController {
// Existing state variables...
final RxList<ChatMessage> currentMessages = <ChatMessage>[].obs;
final RxBool isLoadingMessages = false.obs;
// New state variables for pagination
final RxBool isLoadingOlderMessages = false.obs; // Loading state for older messages
final RxBool hasMoreMessages = true.obs; // Whether there are more messages to load
}
Implementing Pagination Method
/// Load older messages (pagination)
Future<bool> loadOlderMessages(String chatRoomId) async {
try {
// Prevent duplicate loads and check conditions
if (isLoadingOlderMessages.value || !hasMoreMessages.value || currentMessages.isEmpty) {
return false;
}
isLoadingOlderMessages.value = true;
final circleId = chatRoomId;
final oldestMessage = currentMessages.first; // Oldest message currently loaded
// Load older messages through Repository
final olderMessages = await _chatRepository.loadOlderMessages(
circleId: circleId,
chatRoomId: chatRoomId,
beforeDate: oldestMessage.createdAt, // Cursor: time of oldest message
limit: 100,
);
// No more messages to load
if (olderMessages.isEmpty) {
hasMoreMessages.value = false;
return false;
}
// Insert at the beginning of existing message list (already sorted oldest first)
currentMessages.insertAll(0, olderMessages);
// If less than 100, this is the last page
if (olderMessages.length < 100) {
hasMoreMessages.value = false;
}
return true;
} catch (e, stackTrace) {
print('Error loading older messages: $e');
print('Stack trace: $stackTrace');
return false;
} finally {
isLoadingOlderMessages.value = false;
}
}
Implementation Details:
Prevent Duplicate Loads
isLoadingOlderMessagesflag prevents concurrent requestshasMoreMessagesblocks requests when no more messages are available
Determine Cursor Value
currentMessages.first: Oldest message currently loadedPass this message's
createdAtasbeforeDate
Merge Messages
insertAll(0, olderMessages): Insert at the beginning of existing listChronological order is maintained
Detect Last Page
If loaded messages are less than 100, consider it the last page
Set
hasMoreMessages.value = falseto block additional requests
Updating State During Initial Loading
void loadChatMessages(String chatRoomId) {
try {
isLoadingMessages.value = true;
currentMessages.clear();
final circleId = chatRoomId;
// Subscribe to real-time stream
_currentChatMessageStream = _chatRepository.getChatMessagesStream(circleId, chatRoomId).listen(
(messages) {
currentMessages.assignAll(messages);
// If less than 100 messages, no more messages to load
hasMoreMessages.value = messages.length >= 100;
isLoadingMessages.value = false;
_markMessagesAsRead(circleId, chatRoomId);
},
onError: (error) {
print('Error loading messages: $error');
isLoadingMessages.value = false;
},
);
} catch (e) {
print('Error loading chat messages: $e');
isLoadingMessages.value = false;
}
}
3. UI Layer: Scroll Detection and Auto-Loading
Implementing Scroll Listener
class _ChatRoomScreenState extends State<ChatRoomScreen> {
final ScrollController _scrollController = ScrollController();
final ChatController _chatController = Get.find<ChatController>();
@override
void initState() {
super.initState();
// Add scroll listener
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_scrollController.hasClients) {
final currentScroll = _scrollController.position.pixels;
// Load older messages when scroll is near the top
// Trigger auto-load when within 200px
if (currentScroll < 200 && !_chatController.isLoadingOlderMessages.value && _chatController.hasMoreMessages.value) {
_loadOlderMessages();
}
}
}
Future<void> _loadOlderMessages() async {
if (_chatRoomId == null) return;
// Save message count to maintain scroll position
final previousCount = _chatController.currentMessages.length;
// Load older messages
final success = await _chatController.loadOlderMessages(_chatRoomId!);
if (success && mounted) {
// Maintain scroll position (new messages are added at the top)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
final newCount = _chatController.currentMessages.length;
final addedCount = newCount - previousCount;
// Adjust scroll position by estimated height of added messages
// Estimate approximately 60px per message (can be dynamically calculated)
final estimatedHeight = addedCount * 60.0;
final newScrollPosition = _scrollController.position.pixels + estimatedHeight;
if (newScrollPosition <= _scrollController.position.maxScrollExtent) {
_scrollController.jumpTo(newScrollPosition);
}
}
});
}
}
}
Scroll Position Maintenance Logic:
Problem: When new messages are added at the top of the list, scroll position changes
Solution: Adjust scroll position by the estimated height of added messages
Implementation: Use
jumpTo()to immediately change scroll position (no animation)
UI: Loading Indicator and Empty State Handling
ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
itemCount: messages.length + 2, // +1 for loading indicator, +1 for spacing
itemBuilder: (context, index) {
// Show loading indicator at the top
if (index == 0) {
return Obx(() {
if (_chatController.isLoadingOlderMessages.value) {
return Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.lime400),
),
);
}
if (!_chatController.hasMoreMessages.value) {
return Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: Text(
'No more messages',
style: TextStyle(
fontSize: 12,
color: AppTheme.textDarkTertiary,
),
),
);
}
return const SizedBox.shrink();
});
}
// Add empty space at the end (for scrolling)
if (index == messages.length + 1) {
return const SizedBox(height: 20);
}
// Render actual message
final messageIndex = index - 1; // -1 because of loading indicator
final message = messages[messageIndex];
// ... Render message UI
},
)
Performance Optimization and Considerations
1. Firestore Read Cost Optimization
Cost Comparison:
Previous approach: 1000 reads on initial load
Improved approach: 100 reads on initial load + 100 reads per scroll
Cost Reduction: 90% cost savings if user doesn't scroll
2. Network Bandwidth Optimization
Reduced initial loading time: Only 100 messages sent, reducing network latency
Progressive loading: Load only what's needed, reducing total bandwidth usage
3. Memory Management
currentMessagesmaintains only hundreds of messages in memoryOlder messages loaded only when needed, optimizing memory usage
4. Harmony with Real-Time Updates
Maintain
snapshots()stream so new messages are added in real-timePaginated older messages and real-time messages merge naturally
Firestore Index Requirements
Cursor-based pagination requires the following index:
Collection: circles/{circleId}/chatRooms/{chatRoomId}/messages
Fields:
- createdAt (Descending)
- createdAt (Ascending) - used with where clause
Firebase Console automatically provides an index creation link, or you can create it manually if needed.
Application to Personal Chat Rooms
We applied the same pattern to personal chat rooms:
// PersonalChatRepository
Future<List<ChatMessage>> loadOlderPersonalMessages({
required String chatRoomId,
required DateTime beforeDate,
int limit = 100,
}) async {
final query = _getMessagesRef(chatRoomId)
.orderBy('timestamp', descending: true)
.where('timestamp', isLessThan: Timestamp.fromDate(beforeDate))
.limit(limit);
final snapshot = await query.get();
// ... Transform and sort messages
}
Conclusion
We optimized chat message loading by leveraging Firebase Firestore's cursor-based pagination. Key achievements:
Cost Reduction: 90% reduction in initial loading costs
Improved User Experience: Fast initial loading + ability to check older messages when needed
Scalability: Efficiently handles tens of thousands of messages
Real-Time Synchronization: Pagination and real-time updates work seamlessly together
Through these optimizations, we've achieved stable and efficient message loading even in large-scale chat applications.