Lesson 24 of 77 advanced

WebSockets, Socket.IO & Real-time Sync

Building Live Collaboration Features That Actually Work at Scale

Open interactive version (quiz + challenge)

Real-world analogy

HTTP is like sending letters — you write, send, wait for a reply, done. WebSockets are like a phone call — once connected, both sides can talk freely at any time without hanging up and calling back. Socket.IO is a phone system with auto-reconnect: if the call drops, it silently calls back, replaying any missed messages. In a collaboration app, you need the phone call — polling with HTTP is like texting your team every 2 seconds asking 'anything new?'

What is it?

WebSockets provide a persistent bidirectional channel between Flutter and a server, enabling real-time features: chat, presence, live editing, and push notifications. Socket.IO adds auto-reconnect and event semantics on top. The challenge in production is handling connection lifecycle, missed messages during disconnections, and optimistic UI updates correctly.

Real-world relevance

In a SaaS collaboration app: the SocketService singleton maintains one WebSocket connection per session. When a user opens a channel, the ChatBloc subscribes to the SocketService stream filtered for that channel's events. Incoming messages are shown instantly. Outgoing messages are shown immediately as 'pending' (optimistic) and confirmed when the server acks. On network loss, a 'Reconnecting...' banner appears, and when reconnected, the app backfills missed messages from the REST API.

Key points

Code example

import 'dart:async';
import 'package:socket_io_client/socket_io_client.dart' as IO;

// === 1. Socket Service — Singleton, manages lifecycle ===
enum SocketStatus { disconnected, connecting, connected, reconnecting }

class SocketService {
  static final SocketService _instance = SocketService._internal();
  factory SocketService() => _instance;
  SocketService._internal();

  IO.Socket? _socket;
  final _eventController = StreamController<SocketEvent>.broadcast();
  final _statusController = StreamController<SocketStatus>.broadcast();

  Stream<SocketEvent> get events => _eventController.stream;
  Stream<SocketStatus> get status => _statusController.stream;

  void connect({required String url, required String token}) {
    _statusController.add(SocketStatus.connecting);

    _socket = IO.io(url, IO.OptionBuilder()
      .setTransports(['websocket'])
      .setAuth({'token': token})
      .enableAutoConnect()
      .enableReconnection()
      .setReconnectionDelay(1000)
      .setReconnectionDelayMax(30000)
      .setReconnectionAttempts(double.infinity.toInt())
      .build());

    _socket!.onConnect((_) {
      _statusController.add(SocketStatus.connected);
      // Re-join all active rooms after reconnect
      for (final room in _activeRooms) {
        _socket!.emit('join', {'room': room});
      }
    });

    _socket!.onDisconnect((_) => _statusController.add(SocketStatus.disconnected));
    _socket!.onReconnecting((_) => _statusController.add(SocketStatus.reconnecting));

    // Register event listeners
    _socket!.on('new_message', (data) =>
        _eventController.add(NewMessageEvent.fromJson(data as Map<String, dynamic>)));
    _socket!.on('user_typing', (data) =>
        _eventController.add(UserTypingEvent.fromJson(data as Map<String, dynamic>)));
    _socket!.on('message_read', (data) =>
        _eventController.add(MessageReadEvent.fromJson(data as Map<String, dynamic>)));
  }

  final _activeRooms = <String>{};

  void joinChannel(String channelId) {
    _activeRooms.add(channelId);
    _socket?.emit('join', {'room': channelId});
  }

  void leaveChannel(String channelId) {
    _activeRooms.remove(channelId);
    _socket?.emit('leave', {'room': channelId});
  }

  void sendMessage({required String channelId, required String content, required String tempId}) {
    _socket?.emit('send_message', {
      'channel_id': channelId,
      'content': content,
      'temp_id': tempId,  // For optimistic update matching
    });
  }

  void emitTyping(String channelId) => _socket?.emit('typing', {'channel': channelId});

  void disconnect() {
    _socket?.disconnect();
    _activeRooms.clear();
  }

  void dispose() {
    _socket?.dispose();
    _eventController.close();
    _statusController.close();
  }
}

// === 2. Optimistic Message Handling in BLoC ===
class ChatBloc extends Bloc<ChatEvent, ChatState> {
  final SocketService _socketService;
  final ChatRepository _repository;
  StreamSubscription<SocketEvent>? _socketSub;
  String? _lastMessageId;

  ChatBloc(this._socketService, this._repository) : super(const ChatState()) {
    on<JoinChannel>(_onJoinChannel);
    on<SendMessage>(_onSendMessage);
    on<_SocketEventReceived>(_onSocketEvent);
  }

  Future<void> _onJoinChannel(JoinChannel event, Emitter<ChatState> emit) async {
    _socketService.joinChannel(event.channelId);

    // Load history and track last message for gap detection
    final history = await _repository.getMessages(channelId: event.channelId);
    _lastMessageId = history.lastOrNull?.id;
    emit(state.copyWith(messages: history, channelId: event.channelId));

    // Subscribe to socket events for this channel
    _socketSub = _socketService.events
        .whereType<NewMessageEvent>()
        .where((e) => e.channelId == event.channelId)
        .listen((e) => add(_SocketEventReceived(e)));
  }

  Future<void> _onSendMessage(SendMessage event, Emitter<ChatState> emit) async {
    // Optimistic update — show immediately
    final tempMessage = Message.pending(
      id: event.tempId,
      content: event.content,
      senderId: event.senderId,
    );
    emit(state.copyWith(messages: [...state.messages, tempMessage]));

    // Send via socket — confirmation will arrive as a NewMessageEvent
    _socketService.sendMessage(
      channelId: state.channelId!,
      content: event.content,
      tempId: event.tempId,
    );
  }

  void _onSocketEvent(_SocketEventReceived event, Emitter<ChatState> emit) {
    final incoming = event.socketEvent as NewMessageEvent;
    // Replace pending optimistic message or add new message
    final updated = state.messages.map((m) =>
        m.id == incoming.tempId ? incoming.toMessage() : m
    ).toList();
    if (!updated.any((m) => m.id == incoming.message.id)) {
      updated.add(incoming.message);
    }
    emit(state.copyWith(messages: updated));
  }

  @override
  Future<void> close() {
    _socketSub?.cancel();
    _socketService.leaveChannel(state.channelId ?? '');
    return super.close();
  }
}

Line-by-line walkthrough

  1. 1. SocketService is a singleton — one instance, one WebSocket connection for the whole app
  2. 2. StreamController.broadcast() allows multiple subscribers — many BLoCs can listen to the same socket
  3. 3. OptionBuilder configures Socket.IO: WebSocket transport, auth token, auto-reconnect with exponential backoff
  4. 4. onConnect re-joins all active rooms — critical after reconnection, otherwise messages for those channels are missed
  5. 5. _activeRooms tracks which channels are joined so re-subscription survives reconnects
  6. 6. joinChannel adds to the tracked set AND emits the join event to the server
  7. 7. sendMessage includes a tempId — this matches the optimistic message to the server confirmation
  8. 8. ChatBloc subscribes to socket events filtered by channel — no irrelevant events reach this BLoC
  9. 9. JoinChannel loads history and tracks the last message ID for gap detection on reconnect
  10. 10. Optimistic update: pending message is added to state immediately before server confirmation
  11. 11. Socket confirmation arrives as NewMessageEvent — replace the pending message by tempId
  12. 12. In close(), cancel the stream subscription and leave the channel — clean resource management

Spot the bug

class SocketService {
  late IO.Socket _socket;

  void connect(String url, String token) {
    _socket = IO.io(url, {'auth': {'token': token}});
    _socket.on('message', (data) => print(data));
  }

  void joinChannel(String id) {
    _socket.emit('join', id);
  }
}

// Usage in Widget
class ChatScreen extends StatefulWidget { ... }
class _ChatScreenState extends State<ChatScreen> {
  final socket = SocketService();

  @override
  void initState() {
    super.initState();
    socket.connect('wss://api.example.com', token);
  }
}
Need a hint?
Three issues: singleton misuse, connection lifecycle, and reconnect room re-subscription.
Show answer
Bug 1: SocketService is not a singleton — each ChatScreen creates a new SocketService and a new WebSocket connection. Fix: use a GetIt singleton. Bug 2: No dispose() or disconnect() in _ChatScreenState — the socket connection is never closed when the screen is removed, causing memory leaks and stale connections. Fix: override dispose() and call socket.disconnect(). Bug 3: joinChannel only emits the join event once — on reconnect, the channel is not rejoined because there is no _activeRooms tracking. Fix: track joined channels and re-emit joins in onConnect handler.

Explain like I'm 5

Imagine you and your friend have walkie-talkies (WebSocket). Either of you can talk any time — it's instant. Now imagine your walkie-talkie signal drops (network interruption). A smart walkie-talkie (Socket.IO) tries to reconnect automatically, and when it does, it asks 'what did I miss?' and replays those messages. Typing indicators are like pressing the talk button for half a second — everyone hears the click and knows you're about to speak.

Fun fact

The WebSocket protocol (RFC 6455) starts as an HTTP request — the browser or app sends an HTTP Upgrade header, the server responds with 101 Switching Protocols, and from that point the TCP connection is repurposed as a WebSocket. This is why WebSockets work on port 80/443 and pass through most firewalls and corporate proxies that block other protocols.

Hands-on challenge

Design the reconnection flow for a real-time collaboration app: (1) show a 'Reconnecting...' banner when SocketStatus is reconnecting, (2) on successful reconnect, re-join all active channels, (3) backfill missed messages via REST API using the lastReceivedMessageId, (4) merge backfilled messages with the live stream without duplicates.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery