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 thepackage:web_socketpackage, withWebSocketChannelitself now anabstract interface(the old public constructor is gone).channel.ready— aFuturethat 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 DartStream.channel.sink— aWebSocketSinkfor pushing messages out.close()accepts optionalcloseCodeandcloseReasonparameters, which is why the README importspackage:web_socket_channel/status.dartwith astatusprefix and passesstatus.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:
readyis where connection failures surface. Await it inside atryso 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 yourwhileloop will reconnect a socket the widget that wanted it no longer exists to use. - Check
channel.closeCodeafter 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. Aws://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.INTERNETinAndroidManifest.xml, and macOS apps needcom.apple.security.network.clientset totruein the relevant entitlements files. A silent socket failure here is maddening to debug. - Decode JSON defensively.
channel.streamdelivers whatever the server sent — usuallyString, occasionallyList<int>for binary frames. Cast accordingly, wrapjsonDecodein a guard, and route malformed payloads toaddErrorrather than crashing the listener. IOWebSocketChannel.readythrowsWebSocketChannelException. That’s a v3.0.0 breaking change — if you upgraded from 2.x and youron WebSocketExceptionhandlers stopped firing, that’s why.- Testing wants the platform-specific classes. The package ships
IOWebSocketChannel(wrappingdart:io’sWebSocket) andHtmlWebSocketChannel(wrappingdart:html’s) alongside the cross-platform interface.IOWebSocketChannel.connectalso gained options the shared interface doesn’t expose:connectTimeoutin v2.3.0 and acustomClientparameter for supplying your ownHttpClientin v2.4.0 — handy for pinning test certs or pointing at a local server. Both classes also haveinnerWebSocketgetters 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.