Building a Real-Time Chat Feature

Building a robust, real-time chat system is crucial for many mobile apps, from on-demand service platforms to social communities. In this post, I’ll outline the architecture and code considerations involved in creating a Flutter-based chat feature with message synchronization, backend triggers, and advanced user-interface design.


High-Level Approach

  1. Backend Integration: I rely on REST APIs or serverless functions (Firebase Functions, AWS Lambdas, etc.) for sending/receiving messages, updating read statuses, and broadcasting system events (like a user leaving, or the system acknowledging a payment).
  2. Local State & UI: On the client side, Flutter apps use StreamBuilder, ListView, or custom polling to keep a local record of conversation history. When new messages arrive, they’re appended to the local list, and the view updates in real time.
  3. Permissions & Validation: Ensure each user only accesses messages relevant to their order or context. Some apps also rely on role-based or ID-based verification to handle certain logic (e.g., “technicians” vs. “customers”).

Basic Architecture

The chat data model typically includes:

  • Message: Contains id, senderId, text, imageUrl (if any), timestamp, messageType (e.g., normal vs. system).
  • Conversation (optional): Tracks participants, unread counts, etc.
  • Server: Receives new messages through an HTTP endpoint or direct Firestore writes, then broadcasts them to all relevant participants.

For real-time notification, you can use push notifications or background fetch logic. However, many situations also employ short polling if the user is already in the chat screen—this can sometimes simplify concurrency or offline cases.


Flutter UI Example

Below is a condensed snippet illustrating how I might manage message state, handle text+image inputs, and display messages in conversation bubbles.

import 'package:flutter/material.dart';
import 'dart:typed_data';

class ChatMessage {
  final String id;
  final String senderId;
  final String text;
  final String? imageUrl;
  final int timestamp;
  final bool isSystemMessage;

  ChatMessage({
    required this.id,
    required this.senderId,
    required this.text,
    this.imageUrl,
    required this.timestamp,
    this.isSystemMessage = false,
  });
}

class ChatPage extends StatefulWidget {
  final String conversationId;
  const ChatPage({Key? key, required this.conversationId}) : super(key: key);

  @override
  State<ChatPage> createState() => _ChatPageState();
}

class _ChatPageState extends State<ChatPage> {
  final _messageController = TextEditingController();
  List<ChatMessage> _messages = [];
  bool _isUploading = false;
  Uint8List? _pendingImageBytes;

  @override
  void initState() {
    super.initState();
    _fetchMessages(); // initial fetch
    // Optionally set up a periodic polling or streaming subscription
  }

  void _fetchMessages() async {
    // Call backend or DB to load existing messages
    final fetchedMessages = await _mockFetchMessagesFromServer();
    setState(() {
      _messages = fetchedMessages;
    });
  }

  Future<List<ChatMessage>> _mockFetchMessagesFromServer() async {
    // Replace with your actual logic, e.g.:
    await Future.delayed(const Duration(milliseconds: 500));
    return [
      ChatMessage(
        id: 'm1',
        senderId: 'customer123',
        text: 'Hi, I have a question about my order.',
        timestamp: DateTime.now().millisecondsSinceEpoch - 60000,
      ),
      ChatMessage(
        id: 'm2',
        senderId: 'system',
        text: 'System event: The technician is en route.',
        timestamp: DateTime.now().millisecondsSinceEpoch - 30000,
        isSystemMessage: true,
      ),
    ];
  }

  Future<void> _sendMessage({String? text, String? imageUrl}) async {
    // Make sure we have content
    if ((text == null || text.isEmpty) && (imageUrl == null || imageUrl.isEmpty)) {
      return;
    }

    // Construct a local message
    final newMsg = ChatMessage(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      senderId: 'technician456',
      text: text ?? '',
      imageUrl: imageUrl,
      timestamp: DateTime.now().millisecondsSinceEpoch,
    );

    // Update local UI
    setState(() {
      _messages.add(newMsg);
    });

    // Optionally call backend to persist
    // e.g., await MyAPI.sendChatMessage(widget.conversationId, newMsg);
  }

  Widget _buildMessageBubble(ChatMessage msg) {
    if (msg.isSystemMessage) {
      // system bubble
      return Container(
        margin: const EdgeInsets.symmetric(vertical: 8),
        alignment: Alignment.center,
        child: Container(
          padding: const EdgeInsets.all(12),
          decoration: BoxDecoration(
            color: Colors.grey.shade200,
            borderRadius: BorderRadius.circular(16),
          ),
          child: Text(
            msg.text,
            textAlign: TextAlign.center,
            style: const TextStyle(fontStyle: FontStyle.italic),
          ),
        ),
      );
    }

    final bool isMine = (msg.senderId == 'technician456');
    return Container(
      margin: EdgeInsets.fromLTRB(isMine ? 60 : 10, 5, isMine ? 10 : 60, 5),
      padding: const EdgeInsets.all(10),
      decoration: BoxDecoration(
        color: isMine ? Colors.blue.shade100 : Colors.green.shade100,
        borderRadius: BorderRadius.circular(12),
      ),
      child: Column(
        crossAxisAlignment:
            isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
        children: [
          if (msg.text.isNotEmpty)
            Text(msg.text),
          if (msg.imageUrl != null && msg.imageUrl!.isNotEmpty)
            Padding(
              padding: const EdgeInsets.only(top: 8),
              child: Image.network(msg.imageUrl!),
            ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Conversation #${widget.conversationId}'),
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              itemCount: _messages.length,
              itemBuilder: (ctx, i) => _buildMessageBubble(_messages[i]),
            ),
          ),
          _buildTextComposer(),
        ],
      ),
    );
  }

  Widget _buildTextComposer() {
    return SafeArea(
      child: Row(
        children: [
          IconButton(
            icon: const Icon(Icons.add_photo_alternate),
            onPressed: _onSelectImage,
          ),
          Expanded(
            child: TextField(
              controller: _messageController,
              decoration: const InputDecoration(
                hintText: 'Type your message...',
              ),
            ),
          ),
          IconButton(
            icon: const Icon(Icons.send),
            onPressed: () {
              final text = _messageController.text.trim();
              _messageController.clear();
              _sendMessage(text: text);
            },
          ),
        ],
      ),
    );
  }

  // Pseudocode for image picking
  Future<void> _onSelectImage() async {
    // final selectedFile = await pickImage(); // implement picking
    // _isUploading = true; setState(() {});
    // final uploadedUrl = await MyAPI.uploadFile(selectedFile);
    // _isUploading = false; setState(() {});
    // _sendMessage(imageUrl: uploadedUrl);
  }
}

Key Observations

  • The code uses a local _messages list for immediate UI updates.
  • Actual network calls (e.g., to a REST endpoint or Firestore) happen in an async function. This ensures the UI remains responsive.
  • System messages (like “technician is on the way”) are displayed differently than normal user messages.

Handling Advanced Scenarios

  • Unread Indicators: If multiple screens or partial user sessions are involved, track lastSeenMessageTime on the server. In my apps, I typically store each user’s last-read timestamp in a database and then highlight any newer messages.
  • Delivery Receipts: If you need full reliability (like “delivered” or “read” confirmations), design a small queue to push read acknowledgements back to the server.
  • Push Notifications: Typically used if a user is outside the app. On new messages, a backend function triggers a push, and the mobile OS (Android/iOS) displays it. Tapping the notification deep-links into the chat screen.

Concluding Thoughts

Building a chat feature requires careful orchestration between the frontend, backend, and push notification system. By focusing on clarity of message structures, implementing robust synchronization (pull or push), and differentiating user/system flows in the UI, you’ll create a messaging experience that feels immediate and stable. Whether you’re working on a simple 1-on-1 chat or a large multi-participant system, these core principles remain the same.

If done right, a real-time chat not only boosts user engagement but also enables advanced features—such as in-chat payments, scheduling, and shared media—making your mobile app far more valuable in the eyes of both users.




Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • What I Learned from “Infrastructure as Code - The Big Picture”
  • Leveling Up My Dev & PM Toolkit—24 Hours Inside a Full‑Cycle Game‑Development Bootcamp
  • Why I Studied Clinical Depression—and How It Shapes My Work and Leadership
  • Shipping Bug‑Free iOS Apps With a Lean SDET Strategy
  • Beyond the Buzzwords — A Field Manual for Functional, Integration, Smoke and Regression Testing