Skip to main content

Command Palette

Search for a command to run...

Implementing Efficient Chat Pagination in Flutter with Firebase Firestore

Updated
8 min read

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 document

  • Combine where clause with orderBy to 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 updates

  • Client-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:

  1. Cursor-based pagination: where('createdAt', isLessThan: Timestamp.fromDate(beforeDate))

    • beforeDate is the createdAt value of the oldest message currently loaded

    • Only queries messages before this value to prevent duplicates

  2. Query optimization: Using orderBy and where together

    • Firestore may require a composite index

    • Index on createdAt field is automatically created

  3. Consistent sorting: Client-side chronological sorting

    • Firestore fetches newest first with descending: true

    • Re-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:

  1. Prevent Duplicate Loads

    • isLoadingOlderMessages flag prevents concurrent requests

    • hasMoreMessages blocks requests when no more messages are available

  2. Determine Cursor Value

    • currentMessages.first: Oldest message currently loaded

    • Pass this message's createdAt as beforeDate

  3. Merge Messages

    • insertAll(0, olderMessages): Insert at the beginning of existing list

    • Chronological order is maintained

  4. Detect Last Page

    • If loaded messages are less than 100, consider it the last page

    • Set hasMoreMessages.value = false to 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:

  1. Problem: When new messages are added at the top of the list, scroll position changes

  2. Solution: Adjust scroll position by the estimated height of added messages

  3. 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

  • currentMessages maintains only hundreds of messages in memory

  • Older messages loaded only when needed, optimizing memory usage

4. Harmony with Real-Time Updates

  • Maintain snapshots() stream so new messages are added in real-time

  • Paginated 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:

  1. Cost Reduction: 90% reduction in initial loading costs

  2. Improved User Experience: Fast initial loading + ability to check older messages when needed

  3. Scalability: Efficiently handles tens of thousands of messages

  4. 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.