WebSockets, Socket.IO & Real-time Sync
Building Live Collaboration Features That Actually Work at Scale
Open interactive version (quiz + challenge)Real-world analogy
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
- WebSocket vs HTTP Polling — HTTP polling: client requests every N seconds. Long polling: client waits until server has data. WebSocket: persistent bidirectional connection, server pushes instantly. In a real-time chat app, WebSocket latency is ~10ms vs polling latency of up to N seconds. The bandwidth difference is dramatic: one WS frame vs full HTTP request/response overhead.
- WebSocket in Flutter — dart:io — Flutter provides WebSocket via dart:io: WebSocket.connect(uri). Listen to the stream for incoming messages, use socket.add() to send. Handle errors with stream.handleError(). The connection is a Stream — you can use StreamBuilder to reactively update the UI. Remember to close the socket in dispose().
- Socket.IO Client in Flutter — socket_io_client package wraps the Socket.IO protocol: auto-reconnect, namespace support, event-based API (socket.on('message', handler)), and fallback to HTTP long-polling when WebSockets are blocked. Preferred over raw WebSockets when the backend uses Socket.IO (common with Node.js backends).
- Connection Lifecycle Management — Manage connection state explicitly: disconnected → connecting → connected → reconnecting → failed. Expose this as a stream so the UI can show a 'Reconnecting...' banner. On app background (AppLifecycleState.paused), consider disconnecting and reconnecting on foreground to save battery and avoid stale connections.
- Reconnection Strategies — Socket.IO has built-in reconnect with configurable delay and max attempts. For raw WebSockets, implement exponential backoff manually: 1s, 2s, 4s, 8s... up to a max (60s). On each reconnect, re-subscribe to channels and request missed messages since the last received message ID (sequence gap detection).
- Real-time Message Sync Architecture — Messages arrive out of order — use sequence numbers or timestamps to sort. Track the lastReceivedMessageId. On reconnect, send GET /messages?after={lastId} to backfill missed messages, then merge with live WebSocket stream. This ensures no messages are lost during brief disconnections.
- Optimistic Updates — When a user sends a message, add it to the UI immediately with a 'pending' indicator before server acknowledgment. On server ack, update the message status to 'sent'. On failure, mark as 'failed' with a retry option. Users expect WhatsApp/Slack-level responsiveness — waiting for server round-trip before showing the message feels broken.
- Rooms and Channels — In a multi-workspace collaboration app, each workspace and channel is a Socket.IO room. On workspace switch, leave old rooms and join new ones: socket.emit('join', {'room': channelId}). The backend broadcasts to room members only. Each reconnect must rejoin all active rooms.
- Presence and Typing Indicators — 'User X is typing' — debounce keystrokes (300ms), emit a 'typing' event, server broadcasts to channel members. Stop after 3 seconds of inactivity. Online/offline presence: emit 'user_online' on connect, server broadcasts to workspace members, emit 'user_offline' on disconnect (use server-side disconnect event).
- Stream Architecture in Flutter — Keep WebSocket events in a StreamController in a singleton service. Multiple BLoCs/ViewModels subscribe to this stream. Avoid exposing the raw socket to the UI. The SocketService is registered as a singleton in GetIt — one connection, multiple consumers.
- Testing Real-time Features — Use a FakeSocketService that implements the same interface. In tests, manually emit events via the fake's stream to simulate incoming messages. No real network in tests. In integration tests, use a local Socket.IO server or mockserver.
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. SocketService is a singleton — one instance, one WebSocket connection for the whole app
- 2. StreamController.broadcast() allows multiple subscribers — many BLoCs can listen to the same socket
- 3. OptionBuilder configures Socket.IO: WebSocket transport, auth token, auto-reconnect with exponential backoff
- 4. onConnect re-joins all active rooms — critical after reconnection, otherwise messages for those channels are missed
- 5. _activeRooms tracks which channels are joined so re-subscription survives reconnects
- 6. joinChannel adds to the tracked set AND emits the join event to the server
- 7. sendMessage includes a tempId — this matches the optimistic message to the server confirmation
- 8. ChatBloc subscribes to socket events filtered by channel — no irrelevant events reach this BLoC
- 9. JoinChannel loads history and tracks the last message ID for gap detection on reconnect
- 10. Optimistic update: pending message is added to state immediately before server confirmation
- 11. Socket confirmation arrives as NewMessageEvent — replace the pending message by tempId
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- socket_io_client Package (pub.dev)
- Dart WebSocket API (Dart Official)
- WebSocket Protocol RFC 6455 (IETF)
- AppLifecycleState Flutter (Flutter Official)
- Building Real-time Apps with Flutter and Socket.IO (Flutter Community)