← Articles

Flutter + WebSockets: real-time communication

By Ann Tech · 11 January 2026

WebSockets give you a persistent, full-duplex channel between client and server. Unlike HTTP, where the client always initiates, a WebSocket connection lets the server push data at any time. That makes it the right tool for chat, live dashboards, collaborative editing, and anything else where polling would be wasteful or too slow.

Why not just use polling?

Polling is simple but expensive. Every HTTP request opens a TCP connection (or reuses a pooled one), sends headers, waits, and tears down. At 1-second polling with 10,000 users you're making 600,000 requests per minute. WebSockets keep one connection open for the life of the session — the overhead after the initial handshake is just a few bytes per frame.

The trade-off: WebSockets are stateful, which complicates load balancing, horizontal scaling, and reconnect logic. You own that complexity once you leave HTTP.

Setting up the connection in Flutter

Dart's dart:io includes WebSocket out of the box. For production you usually want a wrapper that handles reconnect, ping/pong, and back-pressure.

import 'dart:io';
import 'dart:async';
import 'dart:convert';

class RealtimeClient {
  RealtimeClient(this._url);
  final String _url;

  WebSocket? _socket;
  StreamController<Map<String, dynamic>>? _controller;
  Timer? _reconnectTimer;
  bool _intentionalClose = false;

  Stream<Map<String, dynamic>> get messages =>
      _controller!.stream;

  Future<void> connect() async {
    _intentionalClose = false;
    _controller = StreamController.broadcast();
    await _connect();
  }

  Future<void> _connect() async {
    try {
      _socket = await WebSocket.connect(_url)
          .timeout(const Duration(seconds: 10));
      _socket!.pingInterval = const Duration(seconds: 20);

      _socket!.listen(
        (data) {
          final msg = jsonDecode(data as String) as Map<String, dynamic>;
          _controller!.add(msg);
        },
        onDone: _onDisconnect,
        onError: (_) => _onDisconnect(),
        cancelOnError: false,
      );
    } catch (_) {
      _scheduleReconnect();
    }
  }

  void _onDisconnect() {
    if (!_intentionalClose) _scheduleReconnect();
  }

  void _scheduleReconnect() {
    _reconnectTimer?.cancel();
    _reconnectTimer = Timer(const Duration(seconds: 3), _connect);
  }

  void send(Map<String, dynamic> payload) {
    if (_socket?.readyState == WebSocket.open) {
      _socket!.add(jsonEncode(payload));
    }
  }

  Future<void> close() async {
    _intentionalClose = true;
    _reconnectTimer?.cancel();
    await _socket?.close();
    await _controller?.close();
  }
}

Key decisions made here:

  • broadcast() stream so multiple widgets can subscribe independently.
  • pingInterval keeps NAT mappings alive through routers that close idle TCP connections.
  • Exponential backoff should be added for production (3s, 6s, 12s, cap at 60s).

Integrating with Riverpod

Expose the client as a provider and let widgets subscribe to the message stream:

final realtimeClientProvider = Provider<RealtimeClient>((ref) {
  final client = RealtimeClient('wss://api.example.com/ws');
  ref.onDispose(client.close);
  return client;
});

final liveOrdersProvider = StreamProvider<List<Order>>((ref) {
  final client = ref.watch(realtimeClientProvider);
  return client.messages
      .where((msg) => msg['type'] == 'orders_update')
      .map((msg) => (msg['data'] as List)
          .map((j) => Order.fromJson(j as Map<String, dynamic>))
          .toList());
});

The widget just watches liveOrdersProvider — it knows nothing about WebSockets.

class OrdersScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final orders = ref.watch(liveOrdersProvider);
    return orders.when(
      data: (list) => OrderList(orders: list),
      loading: () => const CircularProgressIndicator(),
      error: (e, _) => ErrorView(message: e.toString()),
    );
  }
}

Authentication

Pass the token during the handshake — WebSocket headers work just like HTTP:

final socket = await WebSocket.connect(
  'wss://api.example.com/ws',
  headers: {'Authorization': 'Bearer $token'},
);

If your server can't read headers (some proxies strip them), encode the token in the URL as a query parameter instead — just be aware it appears in server logs.

Handling app lifecycle

Close the socket when the app goes to background, reconnect when it returns to foreground:

class _OrdersScreenState extends State<OrdersScreen>
    with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    final client = context.read(realtimeClientProvider);
    if (state == AppLifecycleState.resumed) {
      client.connect();
    } else if (state == AppLifecycleState.paused) {
      client.close();
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }
}

Common pitfalls

Not handling reconnect. Mobile networks drop constantly. If you don't reconnect automatically, users see stale data or a stuck spinner without understanding why.

Forgetting to close on dispose. Every StreamController and WebSocket that isn't closed leaks memory. Always close in dispose or via a Riverpod onDispose hook.

Processing messages on the UI thread. If messages arrive fast (e.g., a high-frequency trading feed), JSON decoding can jank the UI. Move heavy deserialization to a separate isolate with compute().

No back-pressure handling. If the server sends faster than the client can process, the buffer grows unboundedly. Add a BufferStrategy or sample with .throttle() from rxdart.

Trusting message order. TCP guarantees order but your server might fan out from multiple workers. Include a sequence number in messages and reorder on the client if correctness depends on order.

Testing WebSocket code

Test the message handler in isolation using a fake StreamController — no real server needed:

test('emits Order when orders_update message arrives', () async {
  final controller = StreamController<Map<String, dynamic>>();
  final fakeClient = FakeRealtimeClient(controller.stream);
  final container = ProviderContainer(
    overrides: [realtimeClientProvider.overrideWithValue(fakeClient)],
  );

  controller.add({
    'type': 'orders_update',
    'data': [{'id': '1', 'status': 'shipped'}],
  });

  await expectLater(
    container.read(liveOrdersProvider.stream),
    emits(isA<List<Order>>().having((l) => l.first.id, 'id', '1')),
  );
});

Checklist before going live

  • Automatic reconnect with exponential backoff
  • Token refresh on 401 / close code 4001
  • Lifecycle-aware connect/disconnect
  • Heartbeat (ping/pong or application-level keepalive)
  • Message deduplication if the server retries on reconnect
  • Load test with realistic concurrent connection count

Sign in to like, dislike, or report.

Flutter + WebSockets: real-time communication — ANN Tech