Chat, notifiche live, dashboard di monitoraggio, tracking di un ordine in tempo reale: quando il polling HTTP non basta più, la risposta è quasi sempre WebSocket. In Flutter la libreria di riferimento è web_socket_channel, mantenuta dal team Dart, che espone il canale come una coppia Stream/StreamSink perfettamente integrabile con StreamBuilder e con qualsiasi soluzione di state management.

Il problema è che il 90% dei tutorial si ferma a WebSocketChannel.connect(). In produzione servono ben altre cose: gestione della disconnessione, riconnessione con backoff, heartbeat per individuare le connessioni "zombie", coda dei messaggi in uscita e reazione al ciclo di vita dell'app. In questo articolo costruiamo un client completo, pezzo per pezzo.

Perché WebSocket (e quando invece no)

WebSocket apre un canale full-duplex persistente su una singola connessione TCP: dopo l'handshake HTTP (upgrade), client e server possono inviarsi messaggi in qualsiasi momento, senza l'overhead di header ripetuti.

Ha senso quando:

  • il server deve spingere dati verso il client (chat, prezzi, posizioni, presenze);
  • la latenza conta (sotto il secondo);
  • il traffico è bidirezionale e frequente.

Non ha senso quando:

  • servono aggiornamenti sporadici a bassa priorità → meglio le push notification (FCM);
  • il flusso è solo server → client e tollera HTTP/2 → valuta SSE (Server-Sent Events), più semplice da gestire e con riconnessione nativa;
  • l'app deve ricevere dati in background o con l'app chiusa: nessun WebSocket sopravvive a lungo in background su iOS. Il socket va chiuso e riaperto al ritorno in foreground.

Setup

dependencies:
  web_socket_channel: ^3.0.1

Il package astrae le differenze tra piattaforme: su mobile/desktop usa dart:io, su Web le WebSocket del browser. WebSocketChannel.connect() è la factory cross-platform.

import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status;

Future<void> demo() async {
  final channel = WebSocketChannel.connect(
    Uri.parse('wss://echo.websocket.org'),
  );

  // Dalla 2.2 in poi: attende l'handshake e propaga gli errori di connessione.
  await channel.ready;

  channel.sink.add('ciao');

  await for (final message in channel.stream) {
    print('ricevuto: $message');
  }

  await channel.sink.close(status.goingAway);
}

Due dettagli che fanno la differenza:

  • await channel.ready: senza di esso un errore di handshake (host irraggiungibile, certificato non valido, 401) emerge solo come errore sullo stream, spesso in un punto del codice dove non lo stai ascoltando → unhandled exception.
  • channel.stream è single-subscription. Se più widget devono ascoltare, serve un StreamController.broadcast() intermedio, come faremo tra poco.

Il problema dei messaggi tipizzati

Un WebSocket trasporta String o List<int>. Nella pratica quasi tutti i backend usano JSON con un campo discriminante. Modelliamolo con le sealed class di Dart 3, così il switch è esaustivo:

sealed class ServerEvent {
  const ServerEvent();

  factory ServerEvent.fromJson(Map<String, dynamic> json) {
    return switch (json['type'] as String) {
      'message' => ChatMessage(
          id: json['id'] as String,
          author: json['author'] as String,
          text: json['text'] as String,
        ),
      'typing' => UserTyping(userId: json['userId'] as String),
      'pong' => const Pong(),
      final unknown => UnknownEvent(unknown),
    };
  }
}

class ChatMessage extends ServerEvent {
  const ChatMessage({required this.id, required this.author, required this.text});
  final String id;
  final String author;
  final String text;
}

class UserTyping extends ServerEvent {
  const UserTyping({required this.userId});
  final String userId;
}

class Pong extends ServerEvent {
  const Pong();
}

class UnknownEvent extends ServerEvent {
  const UnknownEvent(this.type);
  final String type;
}

Regola d'oro: un payload malformato non deve mai uccidere la connessione. Il parsing va sempre protetto da try/catch, altrimenti un solo messaggio sbagliato del backend chiude lo stream per tutti.

Un client resiliente

Ecco il cuore dell'articolo: una classe che incapsula connessione, riconnessione, heartbeat e coda di invio. Espone due stream broadcast (eventi e stato) e non richiede al resto dell'app di sapere nulla di WebSocket.

import 'dart:async';
import 'dart:convert';
import 'dart:math';

import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status;

enum ConnectionState { disconnected, connecting, connected }

class RealtimeClient {
  RealtimeClient({required this.uri, required this.tokenProvider});

  final Uri uri;
  final Future<String> Function() tokenProvider;

  WebSocketChannel? _channel;
  StreamSubscription<dynamic>? _sub;
  Timer? _reconnectTimer;
  Timer? _heartbeatTimer;
  Timer? _pongTimeout;

  int _attempt = 0;
  bool _manuallyClosed = false;
  final List<String> _outbox = [];

  final _events = StreamController<ServerEvent>.broadcast();
  final _state = StreamController<ConnectionState>.broadcast();

  Stream<ServerEvent> get events => _events.stream;
  Stream<ConnectionState> get state => _state.stream;

  Future<void> connect() async {
    if (_channel != null) return;
    _manuallyClosed = false;
    _state.add(ConnectionState.connecting);

    try {
      final token = await tokenProvider();
      final channel = WebSocketChannel.connect(
        uri.replace(queryParameters: {...uri.queryParameters, 'token': token}),
      );
      await channel.ready;

      _channel = channel;
      _attempt = 0;
      _state.add(ConnectionState.connected);

      _sub = channel.stream.listen(
        _onData,
        onError: (Object e, StackTrace s) => _onDisconnected(e),
        onDone: () => _onDisconnected(channel.closeReason),
        cancelOnError: true,
      );

      _startHeartbeat();
      _flushOutbox();
    } catch (e) {
      _onDisconnected(e);
    }
  }

  void send(Map<String, dynamic> payload) {
    final raw = jsonEncode(payload);
    final sink = _channel?.sink;
    if (sink == null) {
      // Coda limitata: evitiamo di accumulare memoria all'infinito.
      if (_outbox.length >= 50) _outbox.removeAt(0);
      _outbox.add(raw);
      return;
    }
    sink.add(raw);
  }

  void _flushOutbox() {
    final pending = List<String>.from(_outbox);
    _outbox.clear();
    for (final raw in pending) {
      _channel?.sink.add(raw);
    }
  }

  void _onData(dynamic raw) {
    _pongTimeout?.cancel();
    try {
      final json = jsonDecode(raw as String) as Map<String, dynamic>;
      _events.add(ServerEvent.fromJson(json));
    } catch (e) {
      // Log, ma non propaghiamo: la connessione resta viva.
    }
  }

  void _onDisconnected(Object? reason) {
    _cleanupSocket();
    _state.add(ConnectionState.disconnected);
    if (_manuallyClosed) return;
    _scheduleReconnect();
  }

  void _scheduleReconnect() {
    _reconnectTimer?.cancel();
    _attempt++;
    // Backoff esponenziale con tetto a 30s + jitter per evitare il thundering herd.
    final base = min(30, pow(2, _attempt).toInt());
    final jitter = Random().nextInt(1000);
    final delay = Duration(milliseconds: base * 1000 + jitter);
    _reconnectTimer = Timer(delay, connect);
  }

  void _startHeartbeat() {
    _heartbeatTimer?.cancel();
    _heartbeatTimer = Timer.periodic(const Duration(seconds: 20), (_) {
      send({'type': 'ping'});
      _pongTimeout?.cancel();
      // Se entro 10s non arriva nulla, la connessione è "zombie": forziamo il reset.
      _pongTimeout = Timer(const Duration(seconds: 10), () {
        _channel?.sink.close(status.goingAway);
        _onDisconnected('pong timeout');
      });
    });
  }

  void _cleanupSocket() {
    _heartbeatTimer?.cancel();
    _pongTimeout?.cancel();
    _sub?.cancel();
    _sub = null;
    _channel = null;
  }

  Future<void> disconnect() async {
    _manuallyClosed = true;
    _reconnectTimer?.cancel();
    await _channel?.sink.close(status.normalClosure);
    _cleanupSocket();
    _state.add(ConnectionState.disconnected);
  }

  Future<void> dispose() async {
    await disconnect();
    await _events.close();
    await _state.close();
  }
}

Perché l'heartbeat è indispensabile

Su rete mobile capita spessissimo: il dispositivo passa da Wi-Fi a 4G, o un NAT intermedio scarta la connessione inattiva. Il socket non riceve alcun evento onDone: resta aperto per il client, ma nessun byte arriverà mai più. Solo un ping periodico con timeout sulla risposta permette di accorgersene e riconnettersi. Se il tuo backend supporta i frame ping/pong nativi del protocollo, puoi usare IOWebSocketChannel.connect(uri, pingInterval: Duration(seconds: 20)) su mobile/desktop — ma non funziona su Web, dove il ping applicativo resta l'unica strada.

Backoff esponenziale, non retry ogni secondo

Riconnettersi ogni secondo con 50.000 utenti che tornano online insieme significa mettere in ginocchio il backend. Raddoppiare il ritardo a ogni tentativo, con un tetto massimo e un po' di jitter casuale, distribuisce il carico nel tempo. Ricordati di azzerare il contatore quando la connessione riesce.

Integrazione con il ciclo di vita e la connettività

Un client realtime deve chiudersi quando l'app va in background (iOS sospende comunque i socket dopo pochi secondi) e riconnettersi quando torna in primo piano o quando la rete ritorna:

class _ChatPageState extends State<ChatPage> {
  late final AppLifecycleListener _lifecycle;
  final client = RealtimeClient(/* ... */);

  @override
  void initState() {
    super.initState();
    client.connect();
    _lifecycle = AppLifecycleListener(
      onResume: client.connect,
      onPause: client.disconnect,
    );
  }

  @override
  void dispose() {
    _lifecycle.dispose();
    client.dispose();
    super.dispose();
  }
}

Lo stesso vale per connectivity_plus: alla ricomparsa della rete puoi chiamare direttamente connect() senza aspettare il prossimo tentativo di backoff, riducendo drasticamente il tempo di ripristino percepito.

Mostrare lo stato in UI

Gli utenti perdonano una disconnessione, non perdonano un'app che finge che vada tutto bene. Esporre state permette di mostrare un banner:

StreamBuilder<ConnectionState>(
  stream: client.state,
  initialData: ConnectionState.connecting,
  builder: (context, snapshot) {
    final connected = snapshot.data == ConnectionState.connected;
    return AnimatedContainer(
      duration: const Duration(milliseconds: 200),
      height: connected ? 0 : 32,
      color: Theme.of(context).colorScheme.errorContainer,
      alignment: Alignment.center,
      child: const Text('Riconnessione in corso…'),
    );
  },
)

Per la lista dei messaggi, invece di un StreamBuilder sullo stream grezzo, conviene accumulare gli eventi in uno stato (Riverpod, BLoC, ValueNotifier): lo stream emette eventi, non lo stato completo della conversazione, e ogni ricostruzione del widget perderebbe la cronologia.

Autenticazione: attenzione al Web

Su mobile e desktop puoi passare header custom:

import 'package:web_socket_channel/io.dart';

final channel = IOWebSocketChannel.connect(
  uri,
  headers: {'Authorization': 'Bearer $token'},
  pingInterval: const Duration(seconds: 20),
);

Sul Web non è possibile: l'API del browser non consente header custom nell'handshake. Le alternative sono il token in query string (ricorda che finisce nei log del server: usa token a vita breve) oppure un primo messaggio applicativo di autenticazione subito dopo la connessione, con il server che chiude il socket se non lo riceve entro N secondi.

Testare il client

web_socket_channel è testabile senza rete: basta far accettare al client un factory di canali e iniettare un StreamChannelController.

import 'package:stream_channel/stream_channel.dart';
import 'package:test/test.dart';

void main() {
  test('emette un ChatMessage quando il server invia un evento message', () async {
    final controller = StreamChannelController<String>();
    final client = RealtimeClient.forTesting(
      channel: WebSocketChannel(controller.local),
    );

    await client.connect();

    controller.foreign.sink.add(
      '{"type":"message","id":"1","author":"Ada","text":"ciao"}',
    );

    expect(await client.events.first, isA<ChatMessage>());
  });
}

Con lo stesso approccio puoi simulare una chiusura improvvisa (controller.foreign.sink.close()) e verificare che scatti la riconnessione, usando fakeAsync per non aspettare davvero i secondi di backoff.

Checklist per la produzione

  • Usa sempre wss://, mai ws:// in produzione.
  • await channel.ready e gestione esplicita degli errori di handshake.
  • Riconnessione con backoff esponenziale + jitter e reset del contatore al successo.
  • Heartbeat applicativo con timeout sul pong.
  • Parsing dei messaggi isolato in try/catch.
  • Chiudi il socket in pause, riapri in resume; ascolta la connettività.
  • Coda in uscita limitata e idempotenza lato server: dopo una riconnessione i messaggi possono essere duplicati.
  • Prevedi un meccanismo di resync (ultimo messageId ricevuto) per recuperare gli eventi persi durante il down.
  • Chiudi sempre StreamController e sottoscrizioni in dispose().

Con queste accortezze un canale realtime smette di essere la parte fragile dell'app e diventa un'infrastruttura su cui costruire chat, presenze e aggiornamenti live senza sorprese.