Skip to content
Blog

Real-Time WebSocket Connections in Flutter

How to use the web_socket_channel package for bidirectional, low-latency Flutter apps — connect, stream, sink, reconnect with backoff, and know when Server-Sent Events are the better fit.

Published on • October 12, 2026

AI Assistant

Polling a REST endpoint every three seconds is the duct tape of real-time UIs. It burns battery and data on radio wakeups, adds latency equal to your polling interval, and falls over the moment several screens need the same updates. Server-Sent Events solve the push half of the problem, but the moment your client also needs to send — a chat message, a presence ping, a cursor position, a typed “user is typing” signal — you’re back to a second request channel.

WebSockets give you one connection that carries traffic both ways. As the Flutter cookbook puts it, they “allow for two-way communication with a server without polling.” The cross-platform way to get one in Dart is the web_socket_channel package from the Dart team’s tools.dart.dev publisher, now at v3.0.3, with around 11.6M total downloads on pub.dev and support for Android, iOS, Linux, macOS, web, and Windows.

The API in four members

The package wraps a WebSocket as a StreamChannel. That single decision is why it plugs into Flutter so cleanly: everything you already know about Stream and StreamSink applies.

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

final channel = WebSocketChannel.connect(Uri.parse('wss://echo.websocket.org'));

await channel.ready;

channel.stream.listen((message) {
  print(message);
  channel.sink.add('received!');
  channel.sink.close(status.goingAway);
});

That’s the whole surface area:

  • WebSocketChannel.connect(uri) — picks the right implementation for the current platform. Under the hood, v3.0.0 made the default implementation an adapter around the package:web_socket package, with WebSocketChannel itself now an abstract interface (the old public constructor is gone).
  • channel.ready — a Future that completes when the connection is established. Added in v2.3.0, and since v2.4.1 the docs are explicit that you should await it before sending anything.
  • channel.stream — messages from the server, as a Dart Stream.
  • channel.sink — a WebSocketSink for pushing messages out. close() accepts optional closeCode and closeReason parameters, which is why the README imports package:web_socket_channel/status.dart with a status prefix and passes status.goingAway.

Two extra getters round it out: protocol for the negotiated subprotocol, and closeCode / closeReason for diagnosing why a connection died.

Wiring it to widget state

The minimal recipe from the WebSocket cookbook is to hold the channel in State and feed its stream to a StreamBuilder:

class _EchoState extends State<EchoPage> {
  final _controller = TextEditingController();
  final _channel = WebSocketChannel.connect(
    Uri.parse('wss://echo.websocket.org'),
  );

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        StreamBuilder(
          stream: _channel.stream,
          builder: (context, snapshot) =>
              Text(snapshot.hasData ? '${snapshot.data}' : ''),
        ),
        TextField(controller: _controller),
        FloatingActionButton(
          onPressed: () => _channel.sink.add(_controller.text),
          child: const Icon(Icons.send),
        ),
      ],
    );
  }

  @override
  void dispose() {
    _channel.sink.close();
    _controller.dispose();
    super.dispose();
  }
}

Two rules matter here. First, create the channel in the State object — the same discipline StreamBuilder’s own API docs demand of streams, since a stream created inside build restarts on every parent rebuild. Second, close the sink in dispose. The cookbook example closes it explicitly for exactly this reason.

For anything more complex than an echo demo, StreamBuilder alone hits a wall: it gives you the latest message, not a running list, and it can’t express “reconnecting”. The usual upgrade is a controller-based service that owns the socket.

A live-updates service with reconnect and backoff

class LiveTicker {
  LiveTicker(this._uri);

  final Uri _uri;
  final _controller = StreamController<Quote>.broadcast();
  WebSocketChannel? _channel;
  int _attempt = 0;
  bool _disposed = false;

  Stream<Quote> get quotes => _controller.stream;

  Future<void> connect() async {
    while (!_disposed) {
      final channel = WebSocketChannel.connect(_uri);
      try {
        await channel.ready;
        _attempt = 0;
        _channel = channel;
        await for (final raw in channel.stream) {
          _controller.add(Quote.fromJson(jsonDecode(raw as String)));
        }
      } on WebSocketChannelException catch (e) {
        _controller.addError(e);
      } finally {
        _channel = null;
      }
      if (_disposed) return;
      final delay = Duration(seconds: _backoffSeconds());
      await Future<void>.delayed(delay);
    }
  }

  int _backoffSeconds() => (_attempt++).clamp(0, 5) + 1;

  void send(String message) => _channel?.sink.add(message);

  void dispose() {
    _disposed = true;
    _channel?.sink.close(status.goingAway);
    _controller.close();
  }
}

The pattern is deliberately plain: loop, connect, await ready, consume the stream until it ends, then wait and try again with a capped exponential backoff. Nothing here is magic — WebSocketChannel deliberately does not auto-reconnect, because backoff policy, auth refresh, and “should we reconnect at all” are app decisions. A few details worth stealing:

  • ready is where connection failures surface. Await it inside a try so a refused connection doesn’t silently leave you listening to nothing.
  • Errors go into a broadcast StreamController. One socket, many widgets — StreamController.broadcast() lets each screen subscribe without stealing events from the others.
  • Guard dispose. Set the flag before closing, or your while loop will reconnect a socket the widget that wanted it no longer exists to use.
  • Check channel.closeCode after the stream ends. It distinguishes a clean server shutdown from a network drop, which tells you whether to reconnect immediately or surface a “signed out” state.

A StreamBuilder over liveTicker.quotes then renders whatever the service emits, including an AsyncSnapshot.withError state whenever _controller.addError fires.

Gotchas that bite in production

  • Use wss:// outside development. The scheme tells the underlying implementation to negotiate TLS. A ws:// URL to a production host also gets blocked as mixed content in Flutter web.
  • Android and macOS need network permission. Per the networking overview, Android apps must declare android.permission.INTERNET in AndroidManifest.xml, and macOS apps need com.apple.security.network.client set to true in the relevant entitlements files. A silent socket failure here is maddening to debug.
  • Decode JSON defensively. channel.stream delivers whatever the server sent — usually String, occasionally List<int> for binary frames. Cast accordingly, wrap jsonDecode in a guard, and route malformed payloads to addError rather than crashing the listener.
  • IOWebSocketChannel.ready throws WebSocketChannelException. That’s a v3.0.0 breaking change — if you upgraded from 2.x and your on WebSocketException handlers stopped firing, that’s why.
  • Testing wants the platform-specific classes. The package ships IOWebSocketChannel (wrapping dart:io’s WebSocket) and HtmlWebSocketChannel (wrapping dart:html’s) alongside the cross-platform interface. IOWebSocketChannel.connect also gained options the shared interface doesn’t expose: connectTimeout in v2.3.0 and a customClient parameter for supplying your own HttpClient in v2.4.0 — handy for pinning test certs or pointing at a local server. Both classes also have innerWebSocket getters for features outside the shared interface.

When Server-Sent Events win

WebSockets are not automatically the right answer. If the flow is purely server → client — live logs, activity feeds, a pricing ticker the client never talks back on — SSE is simpler: it’s plain HTTP, so your existing auth headers, proxies, and load balancers keep working, and reconnection is handled by the browser-ish transport rather than your Dart loop.

The flutter_client_sse package (v2.0.3) makes this a one-liner: SSEClient.subscribeToSSE(...) returns a stream of parsed events, each carrying event, id, and data, and supports both GET and POST subscription requests with custom headers such as Accept: text/event-stream. Since it depends on the standard http package, the same Android permission and macOS entitlement rules apply.

The deciding question is symmetric traffic. Need a reply path? WebSockets. One-directional push over infrastructure you already trust? SSE. And if the “real-time” data is actually read-heavy with occasional writes, don’t reach for either — a normal http.get on a sensible interval is often the honest answer.

Closing

web_socket_channel is small, first-party, and maps almost perfectly onto Flutter’s widget model: a Stream on the way in, a Sink on the way out, and StreamBuilder in the middle. Get await channel.ready right, own the reconnection loop yourself, close the sink in dispose, and you have a production-grade realtime layer in a few dozen lines.

References