Building real-time chat in Flutter with Firebase Realtime Database starts with one key decision: polling or persistent connection. Most developers try polling first.
Fire a GET request on a timer, diff the results, update the UI. It is simple to implement and easy to reason about.
The problem becomes visible quickly in a chat interface. A three-second polling interval means a message can sit on the server for up to three seconds before the recipient sees it. That delay is tolerable for a dashboard. In a conversation, it makes the product feel broken.
The Firebase Realtime Database SDK solves this with a persistent connection model. Understanding what that actually means prevents a category of design mistakes.
Firebase Realtime Database is not a WebSocket library you implement yourself. The SDK opens a single WebSocket connection to Firebase's infrastructure and multiplexes all reads and writes through it. You interact with Dart streams. The network layer is managed by the SDK.
When data at a watched path changes, Firebase pushes a delta to all connected listeners. There is no polling. There is no client asking for new messages. The server notifies connected clients.
This matters for how you structure your Flutter code. Your UI layer consumes a Stream<List<Message>> and reacts to changes. The stream never closes during normal operation. Firebase handles reconnection automatically if the underlying socket drops.
Latency is the obvious problem. But polling also creates unnecessary battery and bandwidth overhead because the client fires network requests even when nothing has changed.
A persistent WebSocket connection does keep the device radio active, so it is not a net zero on power consumption. In practice, for an actively used chat application, the persistent connection is more efficient than periodic polling, because polling forces a new TLS handshake and full HTTP round-trip on each cycle regardless of whether new data exists.
The tradeoff matters less if the chat feature is secondary and infrequently used. For a primary messaging feature, the persistent connection model is clearly the right fit.
Getting the message schema right is more important than most tutorials suggest.
chats/
{chatId}/
messages/
{messageId}/
text: "message body"
senderId: "user_uid"
timestamp: {".sv": "timestamp"} // Firebase server timestamp
status: "sent"The critical detail is timestamp. If you use a client-side DateTime.now(), clock differences across devices produce unpredictable message ordering. Firebase's ServerValue.timestamp is a sentinel value that the SDK replaces with the authoritative server time on write. Order by server timestamp to get consistent ordering across all clients.
A typed Dart model for this structure:
class Message {
final String id;
final String text;
final String senderId;
final DateTime timestamp;
const Message({
required this.id,
required this.text,
required this.senderId,
required this.timestamp,
});
factory Message.fromSnapshot(DataSnapshot snapshot) {
// Firebase SDK returns Map<Object?, Object?> — requires explicit conversion
final data = Map<String, dynamic>.from(snapshot.value as Map);
return Message(
id: snapshot.key!,
text: data['text'] as String,
senderId: data['senderId'] as String,
timestamp: DateTime.fromMillisecondsSinceEpoch(data['timestamp'] as int),
);
}
}The cast from Map<Object?, Object?> to Map<String, dynamic> is required by the current Firebase SDK. The inner field casts are safe as long as your security rules enforce the schema on write.
The repository layer wraps the Firebase stream into a typed Stream<List<Message>>. Pagination matters here. Using .onValue on an unbounded messages path returns the full list on every single change. For a long-running room with hundreds of messages, that becomes expensive.
class MessageRepository {
final String chatId;
MessageRepository(this.chatId);
Stream<List<Message>> messages({int limit = 50}) {
return FirebaseDatabase.instance
.ref('chats/$chatId/messages')
.orderByChild('timestamp')
.limitToLast(limit)
.onValue
.map((event) {
final snapshot = event.snapshot;
if (!snapshot.exists || snapshot.value == null) return <Message>[];
// Use .children which yields DataSnapshot objects directly
return snapshot.children
.map((child) => Message.fromSnapshot(child))
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
});
}
}.limitToLast(50) restricts the live listener to the most recent 50 messages. Older history can be loaded separately on demand. This is a necessary architectural decision for any chat room expected to accumulate significant message volume.
Waiting for server confirmation before displaying a sent message creates visible lag. Optimistic updates render the message immediately in the local state and update status once Firebase confirms the write.
Future<void> sendMessage(String chatId, String text, String senderId) async {
final ref = FirebaseDatabase.instance
.ref('chats/$chatId/messages')
.push(); // Generate key client-side for idempotency
await ref.set({
'text': text,
'senderId': senderId,
'timestamp': ServerValue.timestamp,
'status': 'sent',
});
}Using .push() to generate the message key client-side before the write provides idempotency. If the connection drops after the write succeeds but before the client receives acknowledgment, the SDK retry will attempt to write the same key again. Firebase ignores a second write to an existing key with the same data.
Enabling Firebase's disk persistence with FirebaseDatabase.instance.setPersistenceEnabled(true) queues outbound writes locally when the device is offline. When connectivity returns, the SDK flushes the queue in order.
This does not deliver other users' messages while offline. Messages sent by others during an offline period arrive on reconnect. Applications should surface connection state to users rather than silently queueing messages.
Firebase exposes the connection state through a reserved path:
FirebaseDatabase.instance
.ref('.info/connected')
.onValue
.listen((event) {
final isConnected = event.snapshot.value as bool? ?? false;
// Update UI state accordingly
});One platform-level limitation: when the OS terminates a Flutter app in the background, the WebSocket connection drops. Firebase reconnects automatically when the app returns to the foreground. For messages that arrive while the app is killed, a separate Firebase Cloud Messaging push notification layer is needed. The Realtime Database SDK alone does not deliver background messages to a killed process.
The default Firebase Realtime Database configuration allows unauthenticated reads and writes. This must be locked before any production deployment.
{
"rules": {
"chats": {
"$chatId": {
".read": "auth != null",
"messages": {
"$messageId": {
".write": "auth != null && newData.child('senderId').val() === auth.uid"
}
}
}
}
}
}This requires authentication to read any chat room and prevents a user from writing a message with another user's senderId. Without the senderId validation, any authenticated user can impersonate any other user.
Both databases support real-time listeners. The right choice depends on the access pattern.
| Criterion | Realtime Database | Firestore |
|---|---|---|
| Message ordering | Requires orderByChild and server timestamps | Native, consistent |
| Real-time push | Generally lower latency | Slightly higher |
| Complex queries | Limited | Flexible |
| Offline conflict resolution | Last-write-wins | More structured |
| Pricing | Storage and download bandwidth | Per operation |
For a chat application with a simple access pattern (fetch recent messages, listen for new ones), Realtime Database is a practical choice. If the application needs full-text search over message history, aggregation queries, or complex filtering, Firestore handles those requirements more cleanly.
For PixelPlot's mobile app development services, both databases appear across different projects depending on the data access requirements.
Yes. The Firebase SDK maintains a persistent WebSocket connection internally. Application code works with Dart streams. The SDK handles reconnection, backoff, and connection multiplexing.
It reduces unnecessary network overhead from periodic TLS handshakes and HTTP round-trips. A persistent WebSocket connection does keep the radio active, so the improvement is meaningful but not absolute. Actual impact depends on message frequency and whether the app is foregrounded.
Use .push() to generate the message key on the client before the write. If the SDK retries after a dropped connection, the second write to the same key is idempotent and does not create a duplicate.
The Realtime Database SDK alone cannot deliver messages to a process that the OS has terminated. A Firebase Cloud Messaging integration is required to receive messages in the background or when the app is closed.