flutter-chat-real-time

Use when building real-time chat, messaging, or communication features in Flutter. Covers WebSocket connection lifecycle, ping/pong keep-alive, automatic reconnection, connection state machines, listener-based event architecture, message type hierarchies with polymorphic JSON serialization, typing indicators, read/delivery receipts, presence tracking (online/offline), echo prevention, operation queuing for race conditions, rich text formatting in chat (WYSIWYG to markdown to styled rendering), message bubble rendering, keyboard-aware message list layout, BLoC-based chat component architecture, and stream-based event distribution. Also use when seeing issues with WebSocket disconnects, duplicate messages, stale presence, typing indicator bugs, or chat UI jank during keyboard animation.

Flutter Real-Time Chat — Patterns & Architecture

Production-tested patterns for building real-time chat in Flutter. Extracted from SDKs handling millions of messages.

WebSocket Connection Lifecycle

A chat WebSocket client needs five concerns: connect, authenticate, listen, send, and disconnect. Wrap web_socket_channel with a class that owns all five.

import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';

class ChatSocketClient {
  WebSocketChannel? _channel;
  StreamSubscription? _channelSubscription;
  final _eventController = StreamController<Map<String, dynamic>>.broadcast();
  final _stateController = StreamController<ConnectionState>.broadcast();

  ConnectionState _state = ConnectionState.disconnected;
  bool _intentionalDisconnect = false;
  final String deviceId;

  ChatSocketClient({required this.deviceId});

  Stream<Map<String, dynamic>> get events => _eventController.stream;
  Stream<ConnectionState> get stateChanges => _stateController.stream;
  ConnectionState get state => _state;

  Future<void> connect(String url, String authToken) async {
    if (_state == ConnectionState.connected ||
        _state == ConnectionState.connecting) return;

    _intentionalDisconnect = false;
    _updateState(ConnectionState.connecting);

    try {
      _channel = WebSocketChannel.connect(
        Uri.parse(url),
        protocols: ['websocket'],
      );

      // Wait for connection with timeout
      await _channel!.ready.timeout(
        const Duration(seconds: 5),
        onTimeout: () => throw TimeoutException('Connection timed out'),
      );

      _channelSubscription = _channel!.stream.listen(
        _onMessage,
        onError: (_) => _updateState(ConnectionState.error),
        onDone: _onClosed,
        cancelOnError: false,
      );

      // Authenticate immediately after connection
      send({'type': 'auth', 'token': authToken, 'deviceId': deviceId});
    } catch (e) {
      _updateState(ConnectionState.error);
      rethrow;
    }
  }

  void _onMessage(dynamic data) {
    final json = jsonDecode(data as String) as Map<String, dynamic>;

    // Handle auth response
    if (json['type'] == 'auth_response') {
      if (json['code'] == 200) {
        _updateState(ConnectionState.connected);
      } else {
        _updateState(ConnectionState.error);
      }
      return;
    }

    // Echo prevention — skip events from same device
    if (json['deviceId'] == deviceId) return;

    _eventController.add(json);
  }

  void _onClosed() {
    if (_intentionalDisconnect) {
      _updateState(ConnectionState.disconnected);
    } else {
      _updateState(ConnectionState.error); // Triggers reconnection
    }
  }

  void send(Map<String, dynamic> event) {
    if (_channel == null) throw StateError('Not connected');
    _channel!.sink.add(jsonEncode(event));
  }

  Future<void> disconnect() async {
    _intentionalDisconnect = true;
    await _channelSubscription?.cancel();
    await _channel?.sink.close(1000, 'Normal closure');
    _channel = null;
    _updateState(ConnectionState.disconnected);
  }

  void _updateState(ConnectionState newState) {
    if (_state == newState) return;
    if (!_isValidTransition(_state, newState)) return;
    _state = newState;
    if (!_stateController.isClosed) _stateController.add(newState);
  }
}

Connection State Machine

Never allow arbitrary state transitions. Define a strict state machine to prevent corruption.

disconnected → connecting
connecting   → connected | error | disconnected
connected    → disconnected | error
error        → connecting | disconnected
enum ConnectionState { disconnected, connecting, connected, error }

bool _isValidTransition(ConnectionState from, ConnectionState to) {
  if (from == to) return true;
  switch (from) {
    case ConnectionState.disconnected:
      return to == ConnectionState.connecting;
    case ConnectionState.connecting:
      return to == ConnectionState.connected ||
             to == ConnectionState.error ||
             to == ConnectionState.disconnected;
    case ConnectionState.connected:
      return to == ConnectionState.disconnected ||
             to == ConnectionState.error;
    case ConnectionState.error:
      return to == ConnectionState.connecting ||
             to == ConnectionState.disconnected;
  }
}

Invalid transitions should be logged and rejected, never silently applied.

Ping/Pong Keep-Alive

WebSocket connections die silently without keep-alive. Send a ping every 15 seconds, expect a pong within 3 seconds. No pong means the connection is dead — trigger reconnection.

class PingController {
  static const _pingInterval = Duration(seconds: 15);
  static const _pongTimeout = Duration(seconds: 3);

  final ChatSocketClient _client;
  final ReconnectionController _reconnector;
  Timer? _pingTimer;
  Timer? _pongTimer;
  bool _active = false;

  PingController(this._client, this._reconnector);

  void start() {
    if (_active) return;
    _active = true;
    _pingTimer = Timer.periodic(_pingInterval, (_) => _sendPing());
    _sendPing(); // First ping immediately
  }

  void _sendPing() {
    try {
      _client.send({'type': 'ping'});
      _pongTimer?.cancel();
      _pongTimer = Timer(_pongTimeout, _onPongTimeout);
    } catch (_) {}
  }

  void onPongReceived() {
    _pongTimer?.cancel(); // Connection alive
  }

  void _onPongTimeout() {
    stop();
    _reconnector.startReconnection(); // Dead connection
  }

  void stop() {
    _pingTimer?.cancel();
    _pongTimer?.cancel();
    _active = false;
  }
}

Automatic Reconnection

Reconnect with a fixed 5-second delay. Use an atomic flag to prevent duplicate attempts. Do NOT recursively retry — let the next error/close callback trigger the next attempt.

class ReconnectionController {
  static const _delay = Duration(seconds: 5);

  final ChatSocketClient _client;
  bool _isReconnecting = false;
  bool _manualDisconnect = false;

  ReconnectionController(this._client);

  Future<void> startReconnection() async {
    // Atomic guard — prevent duplicate attempts
    if (_isReconnecting || _manualDisconnect) return;
    _isReconnecting = true;

    try {
      await Future.delayed(_delay);
      await _client.connect(/* url, token */);
    } catch (_) {
      // Don't retry here. The next onError/onClosed callback
      // will call startReconnection() again.
    } finally {
      _isReconnecting = false;
    }
  }

  void setManualDisconnect(bool value) {
    _manualDisconnect = value;
    if (value) _isReconnecting = false;
  }
}

Why no exponential backoff? For chat apps, users expect instant reconnection. A fixed 5-second delay balances server load with user experience.

Operation Queue (Race Condition Prevention)

Rapid connect/disconnect calls (app backgrounding, network flaps) cause race conditions. Queue operations sequentially with a Completer-based queue: enqueue each operation, execute one at a time, apply optional delays, timeout after 5 seconds, and clear the queue on logout. Complete pending completers with errors when clearing to avoid dangling Futures.

Echo Prevention

When you send a message, the server broadcasts it to all connected devices — including yours. Filter events where deviceId matches the current device.

void _onMessage(dynamic data) {
  final json = jsonDecode(data as String);

  // Never filter system events (pong, auth)
  if (json['type'] == 'pong' || json['type'] == 'auth_response') {
    _handleSystemEvent(json);
    return;
  }

  // Filter echoes from same device
  if (json['deviceId'] == _currentDeviceId) return;

  _eventController.add(json);
}

Listener-Based Event Architecture

Use mixin-based listeners registered with unique IDs. This lets multiple screens listen independently without coupling.

mixin MessageListener {
  void onTextMessageReceived(TextMessage message) {}
  void onMediaMessageReceived(MediaMessage message) {}
  void onTypingStarted(TypingIndicator indicator) {}
  void onTypingEnded(TypingIndicator indicator) {}
  void onMessagesDelivered(MessageReceipt receipt) {}
  void onMessagesRead(MessageReceipt receipt) {}
  void onMessageEdited(BaseMessage message) {}
  void onMessageDeleted(BaseMessage message) {}
  void onReactionAdded(ReactionEvent event) {}
  void onReactionRemoved(ReactionEvent event) {}
}

mixin ConnectionListener {
  void onConnected() {}
  void onConnecting() {}
  void onDisconnected() {}
  void onConnectionError(Exception error) {}
}

mixin UserListener {
  void onUserOnline(User user) {}
  void onUserOffline(User user) {}
}

Registration Pattern

class ChatSDK {
  static final Map<String, MessageListener> _messageListeners = {};

  static void addMessageListener(String id, MessageListener listener) {
    _messageListeners[id] = listener;
  }

  static void removeMessageListener(String id) {
    _messageListeners.remove(id);
  }
}

Widget Lifecycle Rule

Register in initState() with a unique ID. Remove in dispose(). Always.

class _ChatScreenState extends State<ChatScreen> with MessageListener {
  late final String _listenerId;

  @override
  void initState() {
    super.initState();
    _listenerId = 'chat_${DateTime.now().millisecondsSinceEpoch}';
    ChatSDK.addMessageListener(_listenerId, this);
  }

  @override
  void dispose() {
    ChatSDK.removeMessageListener(_listenerId);
    super.dispose();
  }

  @override
  void onTextMessageReceived(TextMessage message) {
    setState(() => _messages.add(message));
  }
}

Hardcoded IDs cause collisions when the same screen is opened twice. Missing dispose() removal causes memory leaks and duplicate event handling.

Message Type Hierarchy

Model messages as a sealed class hierarchy. Each type carries its own data while sharing common fields.

abstract class BaseMessage {
  final int id;
  final String senderUid;
  final String receiverUid;
  final String receiverType; // 'user' or 'group'
  final DateTime sentAt;
  final String? muid; // Client-generated ID for deduplication
  final Map<String, dynamic>? metadata;

  BaseMessage({
    required this.id,
    required this.senderUid,
    required this.receiverUid,
    required this.receiverType,
    required this.sentAt,
    this.muid,
    this.metadata,
  });

  factory BaseMessage.fromJson(Map<String, dynamic> json) {
    switch (json['type']) {
      case 'text':    return TextMessage.fromJson(json);
      case 'image':
      case 'video':
      case 'audio':
      case 'file':    return MediaMessage.fromJson(json);
      case 'custom':  return CustomMessage.fromJson(json);
      default:        return CustomMessage.fromJson(json);
    }
  }
}

class TextMessage extends BaseMessage {
  final String text;
  TextMessage({required this.text, required super.id, /* ... */});
}

class MediaMessage extends BaseMessage {
  final String url;
  final String mediaType; // image, video, audio, file
  final String? thumbnailUrl;
  final int? fileSize;
  MediaMessage({required this.url, required this.mediaType, /* ... */});
}

class CustomMessage extends BaseMessage {
  final String customType;
  final Map<String, dynamic> customData;
  CustomMessage({required this.customType, required this.customData, /* ... */});
}

Use muid (message unique ID) for pending→sent deduplication. The server assigns id after persistence, but muid is generated client-side before sending.

Typing Indicators

Send "started" when the user begins typing. Send "ended" after 3 seconds of inactivity. Debounce to avoid flooding the server.

class TypingController {
  Timer? _debounce;
  bool _isTyping = false;
  final void Function(bool isTyping) onTypingChanged;

  TypingController({required this.onTypingChanged});

  void onTextChanged(String text) {
    if (text.isNotEmpty && !_isTyping) {
      _isTyping = true;
      onTypingChanged(true); // Send "started"
    }

    _debounce?.cancel();
    _debounce = Timer(const Duration(seconds: 3), () {
      if (_isTyping) {
        _isTyping = false;
        onTypingChanged(false); // Send "ended"
      }
    });
  }

  void dispose() {
    _debounce?.cancel();
    if (_isTyping) onTypingChanged(false);
  }
}

On the receiving side, use the typingStatus field from the WebSocket event, not metadata. This is a common bug source.

Read/Delivery Receipts

Track three states per message: sent, delivered, read.

class MessageReceipt {
  final int messageId;
  final String senderUid;
  final String receiverUid;
  final ReceiptType type; // delivered, read
  final DateTime timestamp;
}

enum ReceiptType { delivered, read }

Mark messages as delivered when the recipient's app receives them. Mark as read when the message scrolls into the viewport. Batch read receipts — don't send one per message.

Keyboard-Aware Message List

The most common chat UI bug: double keyboard compensation. The message list and the system both try to push content up when the keyboard opens.

Fix: Set resizeToAvoidBottomInset: false on the Scaffold and handle keyboard spacing manually.

Scaffold(
  resizeToAvoidBottomInset: false, // CRITICAL
  body: Column(
    children: [
      Expanded(child: MessageList(/* ... */)),
      MessageComposer(/* ... */),
    ],
  ),
)

Then in your message list, add bottom padding equal to the keyboard height only when the user is scrolled to the bottom. When scrolled up, the list stays still and only the composer moves.

Rich Text in Chat (WYSIWYG → Markdown → Styled Rendering)

Chat rich text has three phases:

  1. Composer (input): WYSIWYG editing with span tracking
  2. Storage: Markdown string (**bold** and _italic_)
  3. Bubble (display): Parse markdown back to styled TextSpan
User types → RichTextEditingController tracks spans
User sends → toMarkdown() converts spans to markdown string
Message stored as markdown
Recipient receives → Formatter parses markdown to AttributedText
AttributedText → styled TextSpan in RichText widget

Formatter Pattern

Each format type (bold, italic, code, link) is a separate formatter class. The message bubble runs all formatters in sequence.

abstract class TextFormatter {
  List<AttributedText> format(String text);
}

class BoldFormatter extends TextFormatter {
  @override
  List<AttributedText> format(String text) {
    // Find **text** patterns, return spans with bold style
  }
}

// Compose formatters
List<TextSpan> renderMessage(String markdown, List<TextFormatter> formatters) {
  var segments = [AttributedText(text: markdown)];
  for (final formatter in formatters) {
    segments = segments.expand((s) => formatter.format(s.text)).toList();
  }
  return segments.map((s) => TextSpan(text: s.text, style: s.style)).toList();
}

Use the same formatter list for both the message list and the composer. Mismatched formatters cause inconsistent rendering between what you type and what others see.

BLoC Pattern for Chat Components

Each chat component (message list, conversations, composer) gets its own BLoC that registers SDK listeners in its constructor and removes them on close().

class MessageListBloc extends Bloc<MessageListEvent, MessageListState>
    with MessageListener {
  late final String _listenerId;

  MessageListBloc() : super(MessageListState.initial()) {
    _listenerId = 'msg_bloc_${DateTime.now().millisecondsSinceEpoch}';
    ChatSDK.addMessageListener(_listenerId, this);

    on<LoadMessages>(_onLoad);
    on<MessageReceived>(_onReceived);
  }

  @override
  void onTextMessageReceived(TextMessage message) {
    add(MessageReceived(message));
  }

  @override
  Future<void> close() {
    ChatSDK.removeMessageListener(_listenerId);
    return super.close();
  }
}

Checklist — Every Chat Feature

  • WebSocket connection has a state machine with validated transitions
  • Ping/pong runs every 15s with 3s pong timeout
  • Reconnection uses atomic guard against duplicate attempts
  • Connect/disconnect operations are queued sequentially
  • Echo prevention filters events by device ID
  • Listeners registered with unique IDs, removed in dispose()/close()
  • Message types use a sealed hierarchy with fromJson factory
  • muid used for pending→sent deduplication
  • Typing indicators debounced with 3s inactivity timeout
  • Scaffold has resizeToAvoidBottomInset: false
  • Same text formatters used in both composer and message list
  • Read receipts batched, not sent per-message