I Built a Real-Time Chat App in Flutter + Firebase — Here's the Full Architecture
Built a scalable real-time chat application using Flutter and Firebase with clean architecture and instant message syncing. This project explains the full system design, including authentication, Firestore structure, and real-time messaging flow.
Building a chat feature sounds simple until you're actually inside it. Messages that appear out of order. Notifications that fire twice. Read receipts that are always one step behind. I've hit all of these, and working on GymTaar — a fitness platform for Nepali gyms — forced me to solve them properly.
This post is a complete walkthrough of how the real-time messaging system in GymTaar works: the Firestore data model, how BLoC handles message state, why we chose Firebase Realtime Database for presence instead of Firestore, how push notifications slot in, and a few things I'd do differently next time. No hand-waving — actual code and actual trade-offs.
- Project context — what is GymTaar?
- Tech stack overview
- Authentication flow
- Firestore data model
- Real-time messaging logic
- State management with BLoC
- Online presence with Firebase RTDB
- Push notifications via FCM
- Media messages (images + files)
- Scalability considerations
- Deployment and CI/CD
- Lessons learned
01 — Project Context: What Is GymTaar?
GymTaar is a gym management and fitness platform built for the Nepali market. Think member check-ins, workout tracking, trainer-to-member communication, and subscription billing — packaged into a single mobile app. The chat feature was added in version 1.1 because trainers kept messaging members through WhatsApp, which meant zero visibility for gym owners and zero audit trail.
So the requirement was: build a real-time, 1-to-1 chat between trainers and members, with read receipts, online presence, push notifications for background messages, and media sharing. And it needed to feel fast — no "loading" skeletons when you open a conversation you've had a hundred messages in.
The app is Flutter (Dart 3.8, SDK target Android 21+, iOS 15+). On the backend it's a mix of Firebase services and a custom Django REST API for business logic. Chat, presence, and notifications are entirely Firebase.
02 — Tech Stack Overview
Before diving into the chat architecture, here's what the app uses (pulled directly from pubspec.yaml):
# Core Firebase
cloud_firestore: ^5.6.10 # messages, conversations
firebase_core: ^3.15.2
firebase_database: ^11.3.10 # online presence only
firebase_messaging: ^15.2.9 # FCM push notifications
# State management
flutter_bloc: ^9.1.1
# Networking
dio: ^5.8.0+1 # for Django API calls
# Local persistence
hive_flutter: ^1.1.0 # offline message cache
flutter_secure_storage: ^9.2.4
# Media
file_picker: ^10.3.2
image_picker: ^1.2.1
flutter_image_compress: ^2.4.0
# Notifications UI
flutter_local_notifications: ^19.3.0
flutter_callkit_incoming: ^2.5.4
One thing worth noting — we also have agora_rtc_engine in there for video/audio calls. Chat and calls share the same conversation thread, so they're architecturally linked even though the transport layer is completely different.
03 — Authentication Flow
GymTaar doesn't use Firebase Auth directly for user management — the Django backend owns user accounts and handles JWT issuance. Firebase Auth runs in parallel, authenticated via custom tokens. This is the pattern you want when you have an existing auth system and need Firebase's real-time features without giving up control over your user model.
The flow looks like this:
// 1. User logs in via Django REST API
final response = await _authRepo.login(email, password);
final jwtToken = response.accessToken;
// 2. Exchange Django JWT for Firebase custom token
final firebaseToken = await _authRepo.getFirebaseToken(jwtToken);
// 3. Sign into Firebase with custom token
await FirebaseAuth.instance.signInWithCustomToken(firebaseToken);
// 4. Save FCM token to Firestore for push notifications
final fcmToken = await FirebaseMessaging.instance.getToken();
await _userRepo.updateFcmToken(userId: userId, token: fcmToken!);
The Django backend has an endpoint that accepts a valid JWT, verifies it, and calls Firebase Admin SDK's createCustomToken(uid) with additional claims like role (trainer/member) and gymId. Those claims are available in Firestore security rules, which matters a lot — it's how we prevent a member at Gym A from reading conversations at Gym B.
Why not Firebase Auth directly? Because your Django backend has business logic around membership status, payment gating, and role assignments. If you let Firebase Auth own users, you end up duplicating that logic or building a sync mechanism. Custom tokens let Firebase handle the real-time layer while your backend stays the source of truth.
04 — Firestore Data Model
Getting the Firestore structure right is probably the single most important decision in a chat app. Query patterns, security rules, and read costs all flow from this. Here's what we landed on:
A few specific decisions worth explaining:
Why denormalize lastMessage into the conversation document?
The conversations list screen — think WhatsApp's main screen — needs to show the latest message preview for every conversation. If you store messages only in subcollections, you'd need a separate query per conversation to get that preview. With hundreds of conversations that's a lot of reads. Denormalizing lastMessage means one document read per conversation row.
Why unreadCount as a map instead of a single int?
Because both participants have independent read states. If you store a single counter, you have a race condition: Trainer reads the message, counter drops to 0. Member hasn't read it yet — now their badge is wrong. A map keyed by userId fixes this cleanly.
Why store senderName in each message?
In group contexts (we may add group chats later), user profile data can change — someone renames their profile. Storing the name at send time means historical messages stay accurate. It's a classic denormalization trade-off: a little redundancy for read consistency.
The readBy map for read receipts
Instead of a simple boolean, readBy stores a timestamp per user. This gives us "delivered at" and "read at" distinction without another collection, and it scales to group chats where you need to know which participants have read.
Watch out: Firestore charges per document read, not per field. A message list query on 50 messages = 50 reads. If your users send heavy media (images, files), the message count can grow fast. Implement pagination properly — don't fetch all messages on open.
05 — Real-Time Messaging Logic
The actual messaging flow is simpler than people expect. Firestore's snapshots() stream does most of the heavy lifting:
Stream<List<MessageModel>> getMessagesStream(String conversationId) {
return _firestore
.collection('conversations')
.doc(conversationId)
.collection('messages')
.orderBy('sentAt', descending: true)
.limit(30) // first load: last 30 messages
.snapshots()
.map((snapshot) => snapshot.docs
.map((doc) => MessageModel.fromFirestore(doc))
.toList());
}
Sending a message is a batch write — the message document and the conversation's lastMessage + updatedAt + unreadCount update all happen atomically. This is important. If you update them separately, you can end up with a race where the conversation list shows a stale preview while the message already exists in the subcollection.
Future<void> sendMessage({
required String conversationId,
required MessageModel message,
required String recipientId,
}) async {
final batch = _firestore.batch();
final convRef = _firestore
.collection('conversations')
.doc(conversationId);
final msgRef = convRef.collection('messages').doc();
// Write the message
batch.set(msgRef, message.toFirestore());
// Atomically update conversation metadata
batch.update(convRef, {
'lastMessage': message.toLastMessageMap(),
'updatedAt': FieldValue.serverTimestamp(),
'unreadCount.$recipientId': FieldValue.increment(1),
});
await batch.commit();
}
Handling pagination (load older messages)
The initial load fetches the last 30 messages. When the user scrolls up to the top, we load the next page using startAfterDocument:
Future<List<MessageModel>> loadOlderMessages({
required String conversationId,
required DocumentSnapshot lastDoc,
}) async {
final snapshot = await _firestore
.collection('conversations')
.doc(conversationId)
.collection('messages')
.orderBy('sentAt', descending: true)
.startAfterDocument(lastDoc)
.limit(20)
.get();
return snapshot.docs
.map((doc) => MessageModel.fromFirestore(doc))
.toList();
}
In the BLoC, the older messages are prepended to the existing list. The StreamSubscription for new messages only listens to documents with sentAt after the initial load timestamp — so you don't get a full re-fetch when older messages load.
06 — State Management With BLoC
GymTaar uses flutter_bloc throughout. For chat, there's a ChatBloc per active conversation. The events and states are fairly straightforward:
// Events
abstract class ChatEvent {}
class ChatSubscribed extends ChatEvent {
final String conversationId;
ChatSubscribed(this.conversationId);
}
class ChatMessageSent extends ChatEvent {
final String text;
final MessageType type;
ChatMessageSent({required this.text, this.type = MessageType.text});
}
class ChatOlderMessagesRequested extends ChatEvent {}
class ChatMessagesUpdated extends ChatEvent {
final List<MessageModel> messages;
ChatMessagesUpdated(this.messages);
}
// States
class ChatState extends Equatable {
final List<MessageModel> messages;
final ChatStatus status;
final bool hasMoreMessages;
final bool isLoadingOlder;
final String? error;
const ChatState({...});
ChatState copyWith({...}) => ChatState({...});
@override
List<Object?> get props => [messages, status, hasMoreMessages, error];
}
The critical part is how the BLoC manages its Firestore stream subscription. The subscription lives inside the BLoC (not in the widget tree), so it survives navigation and widget rebuilds:
class ChatBloc extends Bloc<ChatEvent, ChatState> {
StreamSubscription<List<MessageModel>>? _messageSubscription;
ChatBloc({required ChatRepository chatRepo}) : super(const ChatState()) {
on<ChatSubscribed>(_onSubscribed);
on<ChatMessagesUpdated>(_onMessagesUpdated);
on<ChatMessageSent>(_onMessageSent);
on<ChatOlderMessagesRequested>(_onOlderMessagesRequested);
}
Future<void> _onSubscribed(
ChatSubscribed event,
Emitter<ChatState> emit,
) async {
await _messageSubscription?.cancel();
emit(state.copyWith(status: ChatStatus.loading));
_messageSubscription = _chatRepo
.getMessagesStream(event.conversationId)
.listen((messages) => add(ChatMessagesUpdated(messages)));
}
@override
Future<void> close() {
_messageSubscription?.cancel();
return super.close();
}
}
Important: Always cancel your StreamSubscription in BLoC.close(). Forgetting this is a memory leak — the Firestore listener keeps running even after the user navigates away, racking up reads and potentially causing ghost state updates.
Optimistic UI for message sending
To make sending feel instant, we add the message to the local state before the Firestore write completes. It shows with a "pending" status indicator (a small clock icon). When the stream emits the confirmed message, it replaces the optimistic one. If the write fails, we revert and show an error badge.
07 — Online Presence With Firebase RTDB
This is the part that catches people off guard. Firestore is great for messages but it's not great for presence. Presence needs to update when the app goes to background, when the network drops, when the device disconnects — basically any kind of "I'm no longer here" event.
Firebase Realtime Database has a feature called onDisconnect() that Firestore simply doesn't have. It lets you register an operation that fires server-side the moment a client's connection drops — even if the app crashes, the device loses network, or the process is killed. That's exactly what presence needs.
class PresenceService {
final _rtdb = FirebaseDatabase.instance;
Future<void> setOnline(String userId) async {
final ref = _rtdb.ref('presence/$userId');
// Register the offline write BEFORE setting online
await ref.onDisconnect().set({
'online': false,
'lastSeen': ServerValue.timestamp,
});
// Now mark as online
await ref.set({
'online': true,
'lastSeen': ServerValue.timestamp,
});
}
Stream<UserPresence> watchPresence(String userId) {
return _rtdb
.ref('presence/$userId')
.onValue
.map((event) => UserPresence.fromRTDB(event.snapshot));
}
Future<void> setOffline(String userId) async {
await _rtdb.ref('presence/$userId').update({
'online': false,
'lastSeen': ServerValue.timestamp,
});
}
}
We call setOnline() when the app comes to the foreground and setOffline() when it goes to background — via WidgetsBindingObserver. The onDisconnect() handles the crash/kill/network-drop case. Between these two mechanisms, presence is accurate within a few seconds.
The RTDB data lives under presence/{userId} — a flat structure. We don't nest it because RTDB charges per byte downloaded, and shallow queries are much cheaper than deep tree reads.
08 — Push Notifications via FCM
When a message is sent and the recipient is offline (or in background), we need a push notification. FCM is the obvious choice, and the flow is:
- Sender's app writes the message to Firestore (as above)
- A Firestore trigger (Cloud Function) fires on new message creation
- The function checks whether the recipient has the conversation open (via RTDB presence)
- If they're not active in the chat, it sends an FCM message using the recipient's stored FCM token
- The Flutter app receives it via
firebase_messagingand shows a local notification
// In the Flutter app — FCM setup in main.dart
Future<void> _initFCM() async {
final messaging = FirebaseMessaging.instance;
// Request permissions (iOS)
await messaging.requestPermission(
alert: true, badge: true, sound: true,
);
// Foreground messages — show local notification
FirebaseMessaging.onMessage.listen((message) {
// Don't show if user has the conversation open
if (!_isConversationActive(message.data['conversationId'])) {
_notificationService.showMessageNotification(message);
}
});
// Background tap — navigate to conversation
FirebaseMessaging.onMessageOpenedApp.listen((message) {
_navigationService.navigateToChat(
conversationId: message.data['conversationId'],
);
});
}
FCM token rotation: FCM tokens expire and rotate. Store them in Firestore on app start, on token refresh (via onTokenRefresh listener), and after app updates. A stale token means silent failures — notifications just disappear with no error.
09 — Media Messages (Images and Files)
For image and file sharing, the flow is: compress → upload to Firebase Storage → write the download URL to Firestore. We never write the raw bytes to Firestore — documents have a 1MB limit and even small images blow past that.
Future<String> uploadChatMedia({
required File file,
required String conversationId,
}) async {
// Compress image before upload
final compressed = await FlutterImageCompress.compressWithFile(
file.path,
quality: 72, // 72% quality — barely noticeable, ~60% smaller
minWidth: 1080,
minHeight: 1080,
);
final ref = FirebaseStorage.instance
.ref('chat/$conversationId/${DateTime.now().millisecondsSinceEpoch}.jpg');
final uploadTask = ref.putData(compressed!);
final snapshot = await uploadTask;
return await snapshot.ref.getDownloadURL();
}
Before the upload finishes, we show an optimistic bubble with a progress indicator. The progress comes from the TaskSnapshot stream on uploadTask — Flutter makes this surprisingly easy. If the upload fails (network drop mid-way), the user gets a "tap to retry" on the bubble.
10 — Scalability Considerations
GymTaar is currently live across multiple gyms in Nepal. Not millions of users yet, but the architecture has been stress-tested. A few things that matter when you start growing:
Firestore read costs
The biggest hidden cost in a chat app is message reads. Each page-load of 30 messages = 30 reads. If you have 1,000 active users in chats, that's potentially 30,000 reads per minute — just from opening conversations. Mitigate this with Hive for offline caching: messages already seen are served from Hive, and the Firestore stream only fetches deltas.
// Hive box per conversationId
final box = await Hive.openBox<MessageModel>('chat_$conversationId');
// On new messages from Firestore, merge into Hive
for (final msg in newMessages) {
if (!box.containsKey(msg.id)) {
box.put(msg.id, msg);
}
}
// Initial load: serve from Hive first (instant)
final cached = box.values.toList()
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
emit(state.copyWith(messages: cached, status: ChatStatus.loaded));
Firestore security rules — don't skip these
Poorly written rules are a security risk and a performance problem. Our rules ensure that a conversation is only readable/writable by participants whose gymId claim (from the Firebase custom token) matches the conversation's gymId field. This prevents cross-gym data access at the database level, not just the app level.
Index everything you query
The firestore.indexes.json in the repo has composite indexes for (conversationId, sentAt DESC) and (participants ARRAY_CONTAINS, updatedAt DESC). Without these, Firestore falls back to full collection scans — which means extremely slow queries and possible query rejection beyond a threshold.
Message TTL (future work)
We haven't implemented this yet, but messages older than 12 months could be archived to Firestore's Archive tier or exported to Cloud Storage as JSON. This keeps "hot" reads cheap and the active subcollections lean.
11 — Deployment and CI/CD
The repo uses a main → dev → feature branching strategy. No direct pushes to main or dev — everything goes through PRs. Builds are triggered via GitHub Actions:
# .github/workflows/build.yml (simplified)
on:
push:
branches: [dev]
jobs:
build-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.32.0'
- run: flutter pub get
- run: flutter analyze
- run: flutter test
- run: flutter build apk --release --split-per-abi
On iOS, the pipeline builds and uploads to TestFlight via fastlane. The Play Store gets the AAB. We use flutter_dotenv to keep Firebase config and API keys out of source control — environment-specific .env files are injected via GitHub Secrets at build time.
Firebase config itself (google-services.json for Android, GoogleService-Info.plist for iOS) is also injected at CI time — never committed to the repo.
12 — Lessons Learned
A few things I'd do differently, or things that cost more time than expected:
The batch write pattern for message + conversation metadata is non-negotiable. I tried doing them separately in an early prototype and spent two days debugging a race condition where the conversation list showed stale previews. One batch, always.
Use serverTimestamp(), not DateTime.now(). Client clocks are wrong. Not slightly wrong — sometimes minutes wrong, especially on Android devices that haven't synced in a while. If you order messages by client timestamps, users will see messages arrive out of order. Server timestamps fix this because Firebase generates them, not the device.
RTDB for presence was the right call. I spent about two hours trying to replicate onDisconnect() behavior with Firestore and Cloud Functions. You can do it, but it involves a heartbeat mechanism and a scheduled function to clean up stale presence — extra moving parts for something RTDB handles natively.
Hive for offline cache changed the perceived performance completely. Before Hive, opening a conversation with 200+ messages showed a loading spinner for 600-800ms while Firestore fetched. After Hive, it's instant — you see the cached messages immediately, and new ones trickle in silently. That UX difference is night and day.
Compress images before upload. This one sounds obvious but the difference is real — a typical 4K photo from an Android camera is 8–12MB. At 72% quality with a 1080px max dimension, it drops to 300–600KB. Upload time goes from 8–12 seconds on a Nepali 4G connection to under 2 seconds. Users notice.
Wrapping Up
The full source for GymTaar is on GitHub at CodePulse-Technology/gymtaar-app. The repo has the actual firestore.rules, firestore.indexes.json, and the full BLoC structure — worth reading if you're building something similar.
If you're building a Flutter Firebase chat app and hit a wall on any of this — message ordering, FCM token rotation, BLoC stream management, whatever — feel free to reach out. I'm Nimesh, a freelance developer based in Kathmandu. I work with Flutter + Django stacks for startups and product teams.
📧 regminimesh7@gmail.com | 💬 WhatsApp +977-9814062946 | 🌐 regminimesh.com.np
Looking for a Developer?
I build high-performance mobile apps and web platforms. Available for freelance projects.
View My Services →