[{"data":1,"prerenderedAt":27},["ShallowReactive",2],{"articolo-comunicazione-realtime-in-flutter-con-websocket-client-robusto-riconnessione-e-heartbeat":3,"comments-article-comunicazione-realtime-in-flutter-con-websocket-client-robusto-riconnessione-e-heartbeat":26},{"id":4,"title":5,"slug":6,"excerpt":7,"body":8,"cover_image":9,"cover_remote_url":10,"cover_credit":11,"video_url":15,"status":16,"published_at":17,"meta_title":18,"meta_description":19,"category":20,"author":24},74,"Comunicazione realtime in Flutter con WebSocket: client robusto, riconnessione e heartbeat","comunicazione-realtime-in-flutter-con-websocket-client-robusto-riconnessione-e-heartbeat","Come costruire un client WebSocket affidabile in Flutter con web_socket_channel: connessione, messaggi tipizzati, riconnessione con backoff esponenziale, heartbeat e integrazione con l'UI.","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`\u002F`StreamSink` perfettamente integrabile con `StreamBuilder` e con qualsiasi soluzione di state management.\n\nIl 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.\n\n## Perché WebSocket (e quando invece no)\n\nWebSocket 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.\n\nHa senso quando:\n\n- il server deve **spingere** dati verso il client (chat, prezzi, posizioni, presenze);\n- la latenza conta (sotto il secondo);\n- il traffico è bidirezionale e frequente.\n\nNon ha senso quando:\n\n- servono aggiornamenti sporadici a bassa priorità → meglio le **push notification** (FCM);\n- il flusso è solo server → client e tollera HTTP\u002F2 → valuta **SSE** (Server-Sent Events), più semplice da gestire e con riconnessione nativa;\n- 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.\n\n## Setup\n\n```yaml\ndependencies:\n  web_socket_channel: ^3.0.1\n```\n\nIl package astrae le differenze tra piattaforme: su mobile\u002Fdesktop usa `dart:io`, su Web le WebSocket del browser. `WebSocketChannel.connect()` è la factory cross-platform.\n\n```dart\nimport 'package:web_socket_channel\u002Fweb_socket_channel.dart';\nimport 'package:web_socket_channel\u002Fstatus.dart' as status;\n\nFuture\u003Cvoid> demo() async {\n  final channel = WebSocketChannel.connect(\n    Uri.parse('wss:\u002F\u002Fecho.websocket.org'),\n  );\n\n  \u002F\u002F Dalla 2.2 in poi: attende l'handshake e propaga gli errori di connessione.\n  await channel.ready;\n\n  channel.sink.add('ciao');\n\n  await for (final message in channel.stream) {\n    print('ricevuto: $message');\n  }\n\n  await channel.sink.close(status.goingAway);\n}\n```\n\nDue dettagli che fanno la differenza:\n\n- **`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*.\n- **`channel.stream` è single-subscription**. Se più widget devono ascoltare, serve un `StreamController.broadcast()` intermedio, come faremo tra poco.\n\n## Il problema dei messaggi tipizzati\n\nUn WebSocket trasporta `String` o `List\u003Cint>`. Nella pratica quasi tutti i backend usano JSON con un campo discriminante. Modelliamolo con le *sealed class* di Dart 3, così il `switch` è esaustivo:\n\n```dart\nsealed class ServerEvent {\n  const ServerEvent();\n\n  factory ServerEvent.fromJson(Map\u003CString, dynamic> json) {\n    return switch (json['type'] as String) {\n      'message' => ChatMessage(\n          id: json['id'] as String,\n          author: json['author'] as String,\n          text: json['text'] as String,\n        ),\n      'typing' => UserTyping(userId: json['userId'] as String),\n      'pong' => const Pong(),\n      final unknown => UnknownEvent(unknown),\n    };\n  }\n}\n\nclass ChatMessage extends ServerEvent {\n  const ChatMessage({required this.id, required this.author, required this.text});\n  final String id;\n  final String author;\n  final String text;\n}\n\nclass UserTyping extends ServerEvent {\n  const UserTyping({required this.userId});\n  final String userId;\n}\n\nclass Pong extends ServerEvent {\n  const Pong();\n}\n\nclass UnknownEvent extends ServerEvent {\n  const UnknownEvent(this.type);\n  final String type;\n}\n```\n\nRegola d'oro: **un payload malformato non deve mai uccidere la connessione**. Il parsing va sempre protetto da `try\u002Fcatch`, altrimenti un solo messaggio sbagliato del backend chiude lo stream per tutti.\n\n## Un client resiliente\n\nEcco 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.\n\n```dart\nimport 'dart:async';\nimport 'dart:convert';\nimport 'dart:math';\n\nimport 'package:web_socket_channel\u002Fweb_socket_channel.dart';\nimport 'package:web_socket_channel\u002Fstatus.dart' as status;\n\nenum ConnectionState { disconnected, connecting, connected }\n\nclass RealtimeClient {\n  RealtimeClient({required this.uri, required this.tokenProvider});\n\n  final Uri uri;\n  final Future\u003CString> Function() tokenProvider;\n\n  WebSocketChannel? _channel;\n  StreamSubscription\u003Cdynamic>? _sub;\n  Timer? _reconnectTimer;\n  Timer? _heartbeatTimer;\n  Timer? _pongTimeout;\n\n  int _attempt = 0;\n  bool _manuallyClosed = false;\n  final List\u003CString> _outbox = [];\n\n  final _events = StreamController\u003CServerEvent>.broadcast();\n  final _state = StreamController\u003CConnectionState>.broadcast();\n\n  Stream\u003CServerEvent> get events => _events.stream;\n  Stream\u003CConnectionState> get state => _state.stream;\n\n  Future\u003Cvoid> connect() async {\n    if (_channel != null) return;\n    _manuallyClosed = false;\n    _state.add(ConnectionState.connecting);\n\n    try {\n      final token = await tokenProvider();\n      final channel = WebSocketChannel.connect(\n        uri.replace(queryParameters: {...uri.queryParameters, 'token': token}),\n      );\n      await channel.ready;\n\n      _channel = channel;\n      _attempt = 0;\n      _state.add(ConnectionState.connected);\n\n      _sub = channel.stream.listen(\n        _onData,\n        onError: (Object e, StackTrace s) => _onDisconnected(e),\n        onDone: () => _onDisconnected(channel.closeReason),\n        cancelOnError: true,\n      );\n\n      _startHeartbeat();\n      _flushOutbox();\n    } catch (e) {\n      _onDisconnected(e);\n    }\n  }\n\n  void send(Map\u003CString, dynamic> payload) {\n    final raw = jsonEncode(payload);\n    final sink = _channel?.sink;\n    if (sink == null) {\n      \u002F\u002F Coda limitata: evitiamo di accumulare memoria all'infinito.\n      if (_outbox.length >= 50) _outbox.removeAt(0);\n      _outbox.add(raw);\n      return;\n    }\n    sink.add(raw);\n  }\n\n  void _flushOutbox() {\n    final pending = List\u003CString>.from(_outbox);\n    _outbox.clear();\n    for (final raw in pending) {\n      _channel?.sink.add(raw);\n    }\n  }\n\n  void _onData(dynamic raw) {\n    _pongTimeout?.cancel();\n    try {\n      final json = jsonDecode(raw as String) as Map\u003CString, dynamic>;\n      _events.add(ServerEvent.fromJson(json));\n    } catch (e) {\n      \u002F\u002F Log, ma non propaghiamo: la connessione resta viva.\n    }\n  }\n\n  void _onDisconnected(Object? reason) {\n    _cleanupSocket();\n    _state.add(ConnectionState.disconnected);\n    if (_manuallyClosed) return;\n    _scheduleReconnect();\n  }\n\n  void _scheduleReconnect() {\n    _reconnectTimer?.cancel();\n    _attempt++;\n    \u002F\u002F Backoff esponenziale con tetto a 30s + jitter per evitare il thundering herd.\n    final base = min(30, pow(2, _attempt).toInt());\n    final jitter = Random().nextInt(1000);\n    final delay = Duration(milliseconds: base * 1000 + jitter);\n    _reconnectTimer = Timer(delay, connect);\n  }\n\n  void _startHeartbeat() {\n    _heartbeatTimer?.cancel();\n    _heartbeatTimer = Timer.periodic(const Duration(seconds: 20), (_) {\n      send({'type': 'ping'});\n      _pongTimeout?.cancel();\n      \u002F\u002F Se entro 10s non arriva nulla, la connessione è \"zombie\": forziamo il reset.\n      _pongTimeout = Timer(const Duration(seconds: 10), () {\n        _channel?.sink.close(status.goingAway);\n        _onDisconnected('pong timeout');\n      });\n    });\n  }\n\n  void _cleanupSocket() {\n    _heartbeatTimer?.cancel();\n    _pongTimeout?.cancel();\n    _sub?.cancel();\n    _sub = null;\n    _channel = null;\n  }\n\n  Future\u003Cvoid> disconnect() async {\n    _manuallyClosed = true;\n    _reconnectTimer?.cancel();\n    await _channel?.sink.close(status.normalClosure);\n    _cleanupSocket();\n    _state.add(ConnectionState.disconnected);\n  }\n\n  Future\u003Cvoid> dispose() async {\n    await disconnect();\n    await _events.close();\n    await _state.close();\n  }\n}\n```\n\n### Perché l'heartbeat è indispensabile\n\nSu 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\u002Fpong nativi del protocollo, puoi usare `IOWebSocketChannel.connect(uri, pingInterval: Duration(seconds: 20))` su mobile\u002Fdesktop — ma non funziona su Web, dove il ping applicativo resta l'unica strada.\n\n### Backoff esponenziale, non retry ogni secondo\n\nRiconnettersi 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.\n\n## Integrazione con il ciclo di vita e la connettività\n\nUn 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:\n\n```dart\nclass _ChatPageState extends State\u003CChatPage> {\n  late final AppLifecycleListener _lifecycle;\n  final client = RealtimeClient(\u002F* ... *\u002F);\n\n  @override\n  void initState() {\n    super.initState();\n    client.connect();\n    _lifecycle = AppLifecycleListener(\n      onResume: client.connect,\n      onPause: client.disconnect,\n    );\n  }\n\n  @override\n  void dispose() {\n    _lifecycle.dispose();\n    client.dispose();\n    super.dispose();\n  }\n}\n```\n\nLo 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.\n\n## Mostrare lo stato in UI\n\nGli utenti perdonano una disconnessione, non perdonano un'app che finge che vada tutto bene. Esporre `state` permette di mostrare un banner:\n\n```dart\nStreamBuilder\u003CConnectionState>(\n  stream: client.state,\n  initialData: ConnectionState.connecting,\n  builder: (context, snapshot) {\n    final connected = snapshot.data == ConnectionState.connected;\n    return AnimatedContainer(\n      duration: const Duration(milliseconds: 200),\n      height: connected ? 0 : 32,\n      color: Theme.of(context).colorScheme.errorContainer,\n      alignment: Alignment.center,\n      child: const Text('Riconnessione in corso…'),\n    );\n  },\n)\n```\n\nPer 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.\n\n## Autenticazione: attenzione al Web\n\nSu mobile e desktop puoi passare header custom:\n\n```dart\nimport 'package:web_socket_channel\u002Fio.dart';\n\nfinal channel = IOWebSocketChannel.connect(\n  uri,\n  headers: {'Authorization': 'Bearer $token'},\n  pingInterval: const Duration(seconds: 20),\n);\n```\n\nSul 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.\n\n## Testare il client\n\n`web_socket_channel` è testabile senza rete: basta far accettare al client un factory di canali e iniettare un `StreamChannelController`.\n\n```dart\nimport 'package:stream_channel\u002Fstream_channel.dart';\nimport 'package:test\u002Ftest.dart';\n\nvoid main() {\n  test('emette un ChatMessage quando il server invia un evento message', () async {\n    final controller = StreamChannelController\u003CString>();\n    final client = RealtimeClient.forTesting(\n      channel: WebSocketChannel(controller.local),\n    );\n\n    await client.connect();\n\n    controller.foreign.sink.add(\n      '{\"type\":\"message\",\"id\":\"1\",\"author\":\"Ada\",\"text\":\"ciao\"}',\n    );\n\n    expect(await client.events.first, isA\u003CChatMessage>());\n  });\n}\n```\n\nCon 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.\n\n## Checklist per la produzione\n\n- Usa sempre **`wss:\u002F\u002F`**, mai `ws:\u002F\u002F` in produzione.\n- **`await channel.ready`** e gestione esplicita degli errori di handshake.\n- Riconnessione con **backoff esponenziale + jitter** e reset del contatore al successo.\n- **Heartbeat applicativo** con timeout sul pong.\n- Parsing dei messaggi **isolato in try\u002Fcatch**.\n- Chiudi il socket in `pause`, riapri in `resume`; ascolta la connettività.\n- **Coda in uscita limitata** e idempotenza lato server: dopo una riconnessione i messaggi possono essere duplicati.\n- Prevedi un meccanismo di **resync** (ultimo `messageId` ricevuto) per recuperare gli eventi persi durante il down.\n- Chiudi sempre `StreamController` e sottoscrizioni in `dispose()`.\n\nCon 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.","https:\u002F\u002Fflutter.it\u002Fstorage\u002Farticles\u002Fe574a4be-4514-4277-9355-328bf7268ab6.jpg","https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1644088379091-d574269d422f?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w5NzA2NTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3ODczNzEyOTZ8&ixlib=rb-4.1.0&q=80&w=1080",{"name":12,"author_url":13,"photo_url":14},"Conny Schneider","https:\u002F\u002Funsplash.com\u002F@choys_","https:\u002F\u002Funsplash.com\u002Fphotos\u002Fa-blue-background-with-lines-and-dots-xuTJZ7uD7PI",null,"published","2026-08-22T04:01:37+00:00","WebSocket in Flutter: client realtime robusto","Guida pratica ai WebSocket in Flutter con web_socket_channel: connessione, messaggi tipizzati, riconnessione con backoff, heartbeat e test del client.",{"id":21,"name":22,"slug":23},1,"Guide","guide",{"id":21,"name":25},"Flutter Bot",[],1789120586348]