The Flutter Kit logoThe Flutter Kit
Tutorial

Flutter Chat App Tutorial 2026: Real-Time Messaging with Firebase Firestore

Build a production-grade Flutter chat with Firestore. Data model, StreamBuilder, infinite scroll, typing indicators, read receipts, image uploads, FCM, and security rules.

Ahmed GaganAhmed Gagan
18 min read

Building a real-time chat in Flutter is a rite of passage. Every indie dev tries it, half ship something that breaks at 200 messages, and the other half copy a tutorial from 2021 that uses a flat Firestore collection and bankrupts them at 1,000 MAU. This is the production version for 2026: Firestore for the data, FCM for notifications, Firebase Storage for images, and a data model that scales to roughly 50K MAU before you need to think harder.

Short version: rooms collection, messages subcollection, denormalize the sender's display name and photo into every message, paginate with startAfterDocument, drive the UI with StreamBuilder, push notifications via a Cloud Function that triggers on message writes, and lock everything down with security rules that check membership in the room. The full code is below.

Architecture overview

Any real chat app has four moving parts. The mistakes happen when people conflate them.

  • Auth. Firebase Auth gives you a stable uid per user. Everything in Firestore keys off that uid.
  • Data store. Firestore for messages plus metadata. Cloud Storage for images and files.
  • Real-time transport. Firestore snapshot listeners stream new messages to connected clients with sub-second latency.
  • Notifications. FCM delivers push messages when the app is backgrounded or closed. A Cloud Function triggers on every new message write.

Presence (online/offline) and typing indicators are awkward in pure Firestore because there is no native presence system. You either layer in Realtime Database for onDisconnecthandlers or you write an ephemeral typing subcollection with a short TTL. We cover both below.

Picking the data store

You have three reasonable choices in 2026: Cloud Firestore, Firebase Realtime Database, or Supabase Realtime. They look interchangeable on a comparison page, but for chat workloads they behave very differently.

CapabilityFirestoreRealtime DBSupabase Realtime
Query modelIndexed, composite queriesPath-only, deep filtering hardFull SQL via Postgres
Real-time latency300 to 800 ms50 to 200 ms (fastest)200 to 500 ms
Native presenceNo (use Realtime DB)Yes (onDisconnect)Yes (Presence channels)
Pricing modelPer read / write / GBPer concurrent connection + GBPer row, free tier generous
Cost at 10K MAU chat$40 to $120/mo$80 to $200/mo$25 to $60/mo
Cost at 100K MAU chat$1.2K to $3K/mo$1.5K+ (connection cap pain)$300 to $800/mo
Flutter SDK qualityExcellent (cloud_firestore)Excellent (firebase_database)Excellent (supabase_flutter)
Offline supportBest in classGoodManual with PowerSync

Recommendation: Firestore for any chat under roughly 50K MAU. The queries are flexible enough, offline support is real, and the SDK is the most polished. If you cross 50K MAU and your chat is the core feature (not a side feature), evaluate Supabase. Realtime Database is still useful for presence even when Firestore handles messages, which is what we do below.

Firestore data model: rooms, messages, members, indexes

The shape of your data is 80 percent of the work. Get this wrong and you will rewrite everything in three months when reads spike. The model below scales cleanly to ~50K MAU.

/users/{uid}
  displayName: string
  photoURL: string
  fcmTokens: string[]          // one device == one token
  lastSeenAt: timestamp

/rooms/{roomId}
  type: 'direct' | 'group'
  name: string                 // group only
  photoURL: string             // group only
  memberIds: string[]          // duplicated for array-contains queries
  createdAt: timestamp
  lastMessage: {               // denormalized for the room list screen
    text: string
    senderId: string
    senderName: string
    sentAt: timestamp
  }
  unread: {                    // map of uid -> count, atomic increments
    'uidA': 0,
    'uidB': 3
  }

/rooms/{roomId}/messages/{messageId}
  text: string
  senderId: string
  senderName: string           // DENORMALIZED, don't look up from /users every render
  senderPhotoURL: string
  type: 'text' | 'image' | 'system'
  imageURL: string?            // optional, for image messages
  sentAt: timestamp            // server timestamp
  readBy: string[]             // array of uids that read it

/rooms/{roomId}/typing/{uid}
  isTyping: bool
  updatedAt: timestamp         // TTL: ignore if > 6s old

Two design decisions matter most here.

  • Denormalize sender name and photo into every message. Yes, it duplicates data. Yes, if a user changes their name old messages keep the old name. That is the trade-off you want. The alternative is doing a /users/{uid} lookup for every visible message which costs an extra read per render. Multiply by 50 messages on screen and you triple your Firestore bill for nothing.
  • Use memberIds as an array, not a map. Firestore supports where('memberIds', arrayContains: uid) which is the only practical way to list a user's rooms with one query.

You will need two composite indexes (Firestore will prompt you):

# firestore.indexes.json
{
  "indexes": [
    {
      "collectionGroup": "rooms",
      "fields": [
        { "fieldPath": "memberIds", "arrayConfig": "CONTAINS" },
        { "fieldPath": "lastMessage.sentAt", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "messages",
      "fields": [
        { "fieldPath": "sentAt", "order": "DESCENDING" }
      ]
    }
  ]
}

Setting up auth and the user profile

We are not covering Firebase Auth setup in depth here. If you need it, the Firebase Auth Flutter tutorial walks through Apple Sign In, Google Sign In, and email magic links. What matters for chat is that every authenticated user has a matching /users/{uid} document with their display name, photo, and FCM tokens. Create it on first sign-in and update it whenever the user changes their profile.

Future<void> ensureUserProfile(User user) async {
  final ref = FirebaseFirestore.instance.collection('users').doc(user.uid);
  final snap = await ref.get();
  if (!snap.exists) {
    await ref.set({
      'displayName': user.displayName ?? 'Anonymous',
      'photoURL': user.photoURL ?? '',
      'fcmTokens': <String>[],
      'lastSeenAt': FieldValue.serverTimestamp(),
    });
  } else {
    await ref.update({'lastSeenAt': FieldValue.serverTimestamp()});
  }
}

Sending and streaming messages with StreamBuilder

The core abstraction is a ChatRepository that exposes a stream of messages and a send method. Keep Firestore out of your widgets.

class ChatRepository {
  ChatRepository(this._db, this._auth);
  final FirebaseFirestore _db;
  final FirebaseAuth _auth;

  CollectionReference<Map<String, dynamic>> _messages(String roomId) =>
      _db.collection('rooms').doc(roomId).collection('messages');

  Stream<List<Message>> streamMessages(String roomId, {int limit = 30}) {
    return _messages(roomId)
        .orderBy('sentAt', descending: true)
        .limit(limit)
        .snapshots()
        .map((snap) => snap.docs.map(Message.fromDoc).toList());
  }

  Future<void> sendMessage(String roomId, String text) async {
    final user = _auth.currentUser!;
    final roomRef = _db.collection('rooms').doc(roomId);
    final msgRef = roomRef.collection('messages').doc();

    final batch = _db.batch();
    batch.set(msgRef, {
      'text': text,
      'senderId': user.uid,
      'senderName': user.displayName ?? '',
      'senderPhotoURL': user.photoURL ?? '',
      'type': 'text',
      'sentAt': FieldValue.serverTimestamp(),
      'readBy': [user.uid],
    });
    batch.update(roomRef, {
      'lastMessage': {
        'text': text,
        'senderId': user.uid,
        'senderName': user.displayName ?? '',
        'sentAt': FieldValue.serverTimestamp(),
      },
    });
    await batch.commit();
  }
}

The batch write keeps the room's lastMessage in sync with the latest message in a single atomic operation. Without the batch, your room-list screen flickers because the message lands before the parent document updates.

And the screen itself:

class ChatScreen extends StatelessWidget {
  const ChatScreen({required this.roomId});
  final String roomId;

  @override
  Widget build(BuildContext context) {
    final repo = context.read<ChatRepository>();
    final myUid = context.read<FirebaseAuth>().currentUser!.uid;

    return Scaffold(
      appBar: AppBar(title: const Text('Chat')),
      body: Column(
        children: [
          Expanded(
            child: StreamBuilder<List<Message>>(
              stream: repo.streamMessages(roomId),
              builder: (context, snapshot) {
                final messages = snapshot.data ?? const [];
                return ListView.builder(
                  reverse: true,
                  itemCount: messages.length,
                  itemBuilder: (context, i) {
                    final m = messages[i];
                    return MessageBubble(message: m, isMine: m.senderId == myUid);
                  },
                );
              },
            ),
          ),
          MessageComposer(onSend: (text) => repo.sendMessage(roomId, text)),
        ],
      ),
    );
  }
}

reverse: true on a ListView.builder is the single trick that makes a chat feel right. New messages appear at the bottom, the list scrolls naturally, and you do not need a ScrollController.jumpTo hack on every new message.

Building the chat UI (bubbles, timestamps, day separators)

Three patterns separate "basic Firestore tutorial chat" from "looks like WhatsApp." Implement all three.

  • Asymmetric bubbles. My messages right-aligned in primary color, others left-aligned in surface variant.
  • Inline timestamp grouping. Show the timestamp on the last message of a 5-minute window, not on every bubble.
  • Day separators. "Today", "Yesterday", or the date inserted between messages from different days.
class MessageBubble extends StatelessWidget {
  const MessageBubble({required this.message, required this.isMine});
  final Message message;
  final bool isMine;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Padding(
      padding: EdgeInsets.fromLTRB(
        isMine ? 64 : 12, 4, isMine ? 12 : 64, 4,
      ),
      child: Align(
        alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
          decoration: BoxDecoration(
            color: isMine ? cs.primary : cs.surfaceContainerHighest,
            borderRadius: BorderRadius.only(
              topLeft: const Radius.circular(18),
              topRight: const Radius.circular(18),
              bottomLeft: Radius.circular(isMine ? 18 : 4),
              bottomRight: Radius.circular(isMine ? 4 : 18),
            ),
          ),
          child: Text(
            message.text,
            style: TextStyle(color: isMine ? cs.onPrimary : cs.onSurface),
          ),
        ),
      ),
    );
  }
}

For day separators, walk the messages list once and insert a DateHeader widget whenever the date changes between consecutive messages. Keep that logic in a pure function so it is testable.

Pagination and infinite scroll with cursor queries

A common bug in 2026 is still "the chat shows the latest 30 messages and scrolling up shows nothing." The fix is cursor-based pagination using startAfterDocument. Never use offset-based pagination on Firestore: offsets still cost reads for every skipped document.

class MessagePager {
  MessagePager(this._messages, {this.pageSize = 30});
  final CollectionReference<Map<String, dynamic>> _messages;
  final int pageSize;

  DocumentSnapshot? _lastDoc;
  bool _hasMore = true;
  bool get hasMore => _hasMore;

  Future<List<Message>> loadInitial() async {
    final snap = await _messages
        .orderBy('sentAt', descending: true)
        .limit(pageSize)
        .get();
    _lastDoc = snap.docs.isEmpty ? null : snap.docs.last;
    _hasMore = snap.docs.length == pageSize;
    return snap.docs.map(Message.fromDoc).toList();
  }

  Future<List<Message>> loadMore() async {
    if (!_hasMore || _lastDoc == null) return [];
    final snap = await _messages
        .orderBy('sentAt', descending: true)
        .startAfterDocument(_lastDoc!)
        .limit(pageSize)
        .get();
    if (snap.docs.isNotEmpty) _lastDoc = snap.docs.last;
    _hasMore = snap.docs.length == pageSize;
    return snap.docs.map(Message.fromDoc).toList();
  }
}

Wire it to the scroll controller. When the user scrolls within 200 pixels of the top, call loadMore() and append the older messages to the top of the list. The reverse: true ListView keeps the visible position stable as new items insert.

Typing indicators and presence

Firestore has no native presence. The two practical options:

  • Ephemeral Firestore docs with a timestamp TTL. Write a/rooms/{roomId}/typing/{uid} document withupdatedAt when the user types, and ignore docs older than 6 seconds on the read side. Simple, no extra service.
  • Realtime Database with onDisconnect. The only way to detect "user closed the app" reliably. Use it for online/offline status, keep typing in Firestore.
// Typing in Firestore
Future<void> setTyping(String roomId, String uid, bool typing) async {
  final ref = FirebaseFirestore.instance
      .collection('rooms').doc(roomId)
      .collection('typing').doc(uid);
  if (typing) {
    await ref.set({'isTyping': true, 'updatedAt': FieldValue.serverTimestamp()});
  } else {
    await ref.delete();
  }
}

// Presence in Realtime DB
void setupPresence(String uid) {
  final ref = FirebaseDatabase.instance.ref('presence/$uid');
  ref.onDisconnect().set({'state': 'offline', 'lastSeen': ServerValue.timestamp});
  ref.set({'state': 'online', 'lastSeen': ServerValue.timestamp});
}

Throttle the typing-write to once per 2 seconds. Without throttling, you write to Firestore on every keystroke and burn quota.

Read receipts and unread counts

Two patterns. Read receipts mark which users have seen each message.Unread counts tell the room-list screen which conversations have new messages.

// Mark messages as read when the user opens the chat
Future<void> markRead(String roomId, String uid, List<String> messageIds) async {
  final batch = FirebaseFirestore.instance.batch();
  for (final id in messageIds) {
    final ref = FirebaseFirestore.instance
        .collection('rooms').doc(roomId)
        .collection('messages').doc(id);
    batch.update(ref, {'readBy': FieldValue.arrayUnion([uid])});
  }
  // Reset the user's unread count for this room
  batch.update(
    FirebaseFirestore.instance.collection('rooms').doc(roomId),
    {'unread.$uid': 0},
  );
  await batch.commit();
}

Increment the per-user unread count in the Cloud Function that triggers on message writes (see the FCM section). Doing it client-side from the sender is wrong because the sender does not know who else is in the room.

Sending images: Firebase Storage + upload progress

Image messages follow a two-step flow: upload the file to Cloud Storage, then write a message document with the download URL.

Future<void> sendImage(String roomId, File file) async {
  final user = FirebaseAuth.instance.currentUser!;
  final msgId = FirebaseFirestore.instance.collection('rooms').doc().id;
  final path = 'rooms/$roomId/$msgId.jpg';
  final ref = FirebaseStorage.instance.ref(path);

  final task = ref.putFile(file, SettableMetadata(contentType: 'image/jpeg'));
  task.snapshotEvents.listen((s) {
    final pct = s.bytesTransferred / s.totalBytes;
    // Push to a stream the composer listens to for the progress bar
  });
  await task;
  final url = await ref.getDownloadURL();

  await FirebaseFirestore.instance
      .collection('rooms').doc(roomId)
      .collection('messages').doc(msgId)
      .set({
        'type': 'image',
        'imageURL': url,
        'senderId': user.uid,
        'senderName': user.displayName ?? '',
        'senderPhotoURL': user.photoURL ?? '',
        'sentAt': FieldValue.serverTimestamp(),
        'readBy': [user.uid],
      });
}

Compress before upload. A 4 MB iPhone photo uploaded raw costs more per send than the entire chat for a small app. Use flutter_image_compress with quality 70 and max dimension 1600 px. Average image drops to 150 to 300 KB with no perceptible quality loss.

Push notifications for new messages (FCM)

Client-side message sending should never trigger push notifications directly. Use a Cloud Function that fires on message writes, looks up the room members, and sends FCM to anyone not actively viewing the room.

// functions/src/onMessageCreated.ts
import { onDocumentCreated } from 'firebase-functions/v2/firestore';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import { getMessaging } from 'firebase-admin/messaging';

export const onMessageCreated = onDocumentCreated(
  'rooms/{roomId}/messages/{messageId}',
  async (event) => {
    const msg = event.data?.data();
    const { roomId } = event.params;
    if (!msg) return;

    const roomSnap = await getFirestore().doc(`rooms/${roomId}`).get();
    const room = roomSnap.data();
    const recipients = (room?.memberIds || []).filter((u: string) => u !== msg.senderId);
    if (recipients.length === 0) return;

    // Bump unread counts atomically
    const unreadUpdates: Record<string, any> = {};
    recipients.forEach((uid: string) => {
      unreadUpdates[`unread.${uid}`] = FieldValue.increment(1);
    });
    await getFirestore().doc(`rooms/${roomId}`).update(unreadUpdates);

    // Fetch FCM tokens for all recipients
    const userDocs = await getFirestore()
      .collection('users')
      .where('__name__', 'in', recipients.slice(0, 30))
      .get();
    const tokens = userDocs.docs.flatMap((d) => d.data().fcmTokens || []);
    if (tokens.length === 0) return;

    await getMessaging().sendEachForMulticast({
      tokens,
      notification: {
        title: msg.senderName || 'New message',
        body: msg.type === 'image' ? 'Sent a photo' : msg.text,
      },
      data: { roomId, type: 'chat' },
    });
  }
);

On the Flutter side, listen for the FCM token, write it into the user'sfcmTokens array on login, and remove it on logout. Handle the notification tap via FirebaseMessaging.onMessageOpenedApp and navigate to the room withcontext.go('/chat/$roomId'). The dedicated Firebase Flutter tutorial covers token lifecycle in detail.

Security rules: who can read and write what

The number one reason chat apps leak data is permissive security rules. The default "allow read, write: if true" that Firebase ships with is for development only. Lock everything to membership.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    function isSignedIn() { return request.auth != null; }
    function isMember(roomId) {
      return isSignedIn() &&
        request.auth.uid in get(/databases/$(database)/documents/rooms/$(roomId)).data.memberIds;
    }

    match /users/{uid} {
      allow read: if isSignedIn();
      allow write: if request.auth.uid == uid;
    }

    match /rooms/{roomId} {
      allow read: if isMember(roomId);
      allow create: if isSignedIn() && request.auth.uid in request.resource.data.memberIds;
      allow update: if isMember(roomId);
      allow delete: if false; // never client-side

      match /messages/{messageId} {
        allow read: if isMember(roomId);
        allow create: if isMember(roomId) &&
                         request.resource.data.senderId == request.auth.uid;
        allow update: if isMember(roomId) &&
                         request.resource.data.diff(resource.data).affectedKeys()
                           .hasOnly(['readBy']);
        allow delete: if false;
      }

      match /typing/{uid} {
        allow read: if isMember(roomId);
        allow write: if isMember(roomId) && request.auth.uid == uid;
      }
    }
  }
}

The clever bit is the update rule for messages: clients can only modify thereadBy field, never the text or the sender. That stops one user from rewriting another user's message. Run the Firestore rules emulator in CI to assert these behaviors hold as you evolve the rules.

Scaling considerations (sharding, denormalization, cost)

Firestore is fine for chat up to roughly 50K MAU on a single-region project. Past that, three things start to bite.

  • Hot rooms. A 5,000-member group chat exceeds Firestore's 1 write-per-second-per-document soft limit on the room metadata. Solution: stop updatinglastMessage on every send for huge rooms, or shard the room metadata into/rooms/{roomId}/shards/{shardId}.
  • Read amplification. A user opening the room list with 50 conversations triggers 50 reads just for the metadata, plus snapshot listener costs for any active rooms. Cap the room-list query to 20 most-recent rooms and lazy-load the rest.
  • Storage cost. Images dominate storage. Set a Cloud Storage lifecycle rule to move images older than 90 days to Coldline storage. 80 percent cost cut for chat images that nobody ever re-views.

When you cross 100K MAU and chat is your primary feature, you have a real decision: stay on Firestore and pay the bill, or migrate the messages subcollection to Supabase Realtime which gets dramatically cheaper at scale. The Supabase vs Firebase Flutter comparison covers the migration math.

Common chat anti-patterns

  • Storing the full user profile inside every message. DenormalizedisplayName and photoURL only. Embedding the whole user document balloons message size and locks old messages to stale profile data forever.
  • Offset-based pagination. Firestore charges for every skipped document onoffset. Always use startAfterDocument cursors.
  • Sending push notifications from the client. The recipient's device cannot push to other devices. Always use a Cloud Function on message write.
  • Writing typing state on every keystroke. Throttle to once per 2 seconds. Otherwise you are paying for the user's typing speed.
  • Querying the user profile for every visible bubble. If the message does not have the sender name denormalized, fix the message. Do not paper over the bug with extra reads.
  • Permissive security rules in production. Anything looser than "member of the room" is a data leak waiting to happen.
  • Skipping batch writes. Updating the room's lastMessagein a separate call from the message write causes UI flicker and inconsistency on failure.
  • Forgetting to compress images. A 4 MB photo upload costs more than the rest of the chat combined.

What The Flutter Kit ships

The Flutter Kit ships a complete chat module that follows every pattern in this guide: Firestore rooms and messages subcollection, denormalized sender fields, cursor-based pagination, typing indicators via ephemeral docs, FCM-on-write Cloud Function, image upload with compression and progress, read receipts and unread counts, and security rules that have been run through the Firestore emulator in CI. Both 1-to-1 and group chats are wired. Drop it in and you have a working chat feature in an afternoon.

$69 one-time, unlimited commercial projects. See the full integration list on the features page or go straight to checkout.

Final recommendation

If you are building chat as a feature inside a Flutter app in 2026, Firestore is still the right default. The SDK is the best, offline support is real, the security rules system is mature, and the cost is reasonable until you cross 50K MAU. Build with the data model in this guide, denormalize the sender fields, paginate with cursors, and put the push notification logic in a Cloud Function. If chat is your entire product and you expect to grow past 100K MAU fast, prototype on Firestore and budget a migration to Supabase Realtime for year two.

Share this article

Ready to ship your Flutter app faster?

The Flutter Kit gives you a production-ready Flutter codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Flutter Kit