skip to content

In Flutter with web_socket_channel, how do you open a WebSocket, receive and send messages, and close it when the screen goes away?

level: juniorimportance: must knowfreq 64%

answer

  1. a stream and a sink
  2. WebSocketChannel.connect(Uri)
  3. await channel.ready before sending
  4. sink.add to send
  5. cancel and sink.close in dispose

basics

~10 s

Call WebSocketChannel.connect(uri), await channel.ready, listen to channel.stream for incoming messages and call channel.sink.add to send. In the State's dispose, cancel the subscription and call channel.sink.close().

solid answer

~30 s

`web_socket_channel` wraps a socket as a `StreamChannel`: `WebSocketChannel.connect(Uri.parse('wss://...'))` returns a channel synchronously, and `await channel.ready` confirms the handshake succeeded. Incoming frames arrive on `channel.stream` as `String` for text or `List<int>` for binary; I listen once, usually in a repository or controller rather than in `build`, and decode each message into a model. Outgoing messages go through `channel.sink.add(...)`. The channel lives as long as its owner: in a `StatefulWidget`, created in `initState` and torn down in `dispose` by cancelling the subscription and calling `channel.sink.close()`. Forgetting that leaves the socket open after the user has left the screen.

code

dart · 51 lines
dart
import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

class LiveScore extends StatefulWidget {
  const LiveScore({super.key, required this.matchId});
  final String matchId;

  @override
  State<LiveScore> createState() => _LiveScoreState();
}

class _LiveScoreState extends State<LiveScore> {
  late final WebSocketChannel _channel;
  StreamSubscription<dynamic>? _sub;
  String _score = '-';

  @override
  void initState() {
    super.initState();
    _channel = WebSocketChannel.connect(Uri.parse('wss://scores.example.com/live'));
    _start();
  }

  Future<void> _start() async {
    try {
      await _channel.ready;
    } on Object {
      if (mounted) setState(() => _score = 'offline');
      return;
    }
    if (!mounted) return;
    _sub = _channel.stream.listen((raw) {
      final json = jsonDecode(raw as String) as Map<String, dynamic>;
      setState(() => _score = json['score'] as String);
    });
    _channel.sink.add(jsonEncode({'action': 'subscribe', 'match': widget.matchId}));
  }

  @override
  void dispose() {
    _sub?.cancel();
    _channel.sink.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Text(_score);
}

go deeper

for a junior

Know the four calls by heart: connect with a Uri, await ready, listen to stream, add to sink, and close the sink when the owner is disposed.

for a middle

Explain why the socket must live outside build, what types arrive on the stream, and why only one listener can subscribe.

for a senior

Show where the socket belongs in the architecture so reconnection, parsing and errors are handled once, not in every screen.

for a principal

Decide when a socket is the right transport at all versus polling or push notifications, given battery, backend cost and background limits.

## The package and its shape Flutter has no WebSocket class of its own; the standard client is the **`web_socket_channel`** package from the Dart team (version 3.x). It presents a connection as a **`StreamChannel`**, which is two familiar Dart types glued together: - **`channel.stream`**: a `Stream` of messages from the server. A text frame arrives as a `String`, a binary frame as a `List<int>` (a `Uint8List`). - **`channel.sink`**: a `WebSocketSink` for messages to the server. `sink.add(String)` sends a text frame, `sink.add(List<int>)` a binary one. The same code runs on Android, iOS, desktop and the web, because `WebSocketChannel.connect` picks the right implementation for the platform. ## Opening the connection 1. **Connect.** `WebSocketChannel.connect(Uri.parse('wss://scores.example.com/live'))` returns a channel immediately, but the network handshake has not happened yet. 2. **Await `ready`.** The `ready` future completes when the connection is established, or completes with an error if it could not be. The package documents that it must complete before you send data. 3. **Listen once.** Subscribe to `channel.stream` in one place and turn raw messages into typed events there. ## Sending and receiving Most servers speak JSON over text frames, so a small layer does the translation both ways: - incoming: `channel.stream.map((raw) => ScoreUpdate.fromJson(jsonDecode(raw as String) as Map<String, dynamic>))`; - outgoing: `channel.sink.add(jsonEncode({'action': 'subscribe', 'match': matchId}))`. Keeping this in a repository or controller, not in widgets, means the UI only sees `ScoreUpdate` objects and the socket can be replaced on reconnect without touching the screen. ## Closing it with the owner A socket has a lifetime, and something must own it. In a `StatefulWidget` that is the `State`: | Where | What to do | |---|---| | `initState` | create the channel, start the subscription | | incoming handler | update state; check `mounted` before `setState` if work was async | | `dispose` | `subscription.cancel()`, then `channel.sink.close()` | In a state-management setup the owner is the provider, bloc or controller instead, with the same two calls in its own dispose or close method. Two common mistakes: - **Connecting inside `build`**: every rebuild opens a new socket, and the old ones are never closed. - **Never closing**: navigating away leaves the connection open, still receiving data, until the app is killed or the server times it out. `sink.close()` also accepts an optional close code and reason, and after the connection ends `channel.closeCode` and `channel.closeReason` report what the server sent. ## Decoding messages defensively A socket delivers whatever the server sends, including message types this build of the app has never seen. The decoding layer should therefore: - **cast deliberately**: `raw as String` for text protocols, and treat a `List<int>` as a protocol error if the server is not supposed to send binary; - **catch per message**: wrap `jsonDecode` and `fromJson` for each message in `try`/`catch`, log and skip a bad one, so a single malformed update does not surface as an uncaught error from the `onData` callback; - **ignore unknown message types** rather than failing, so a newer server can add events without breaking older app versions. ## Testing without a server Because `WebSocketChannel` is an interface, the class that owns the socket can take a factory, `WebSocketChannel Function(Uri uri)`, instead of calling `WebSocketChannel.connect` directly. Production passes the real `connect`; a unit test passes a function returning a fake channel whose stream it controls, then asserts what the repository publishes and what it writes to the sink. That keeps socket handling testable in plain Dart tests, with no network and no widget tree. ## What this looks like on screen For a live-scores screen the flow is: open the socket when the screen appears, send a subscribe message naming the match after `ready`, show each `ScoreUpdate` as it arrives, and close when the user leaves. The Flutter docs' own example binds `channel.stream` directly to a `StreamBuilder`, which is fine for a demo; production code usually puts a repository in between so reconnection, parsing and error handling live in one place.

  • Why not bind channel.stream to two StreamBuilders on the same screen?
    The channel's stream is a single-subscription stream: a second listener fails with a 'Stream has already been listened to' error. Listen once in a repository and expose a broadcast stream or a state object that many widgets can watch.
  • What types arrive on channel.stream?
    A text frame arrives as a `String` and a binary frame as a `List<int>`, so the stream is typed `dynamic`. Cast deliberately (`raw as String`) and decode JSON yourself; the package does no parsing.

The channel is a two-lane road with a toll gate: stream is the inbound lane, sink the outbound lane, and ready is the gate lifting; nothing should drive out before it opens.

saying these in an interview costs you the question

  • Calling WebSocketChannel.connect inside build() is fine because it is cheap
  • WebSocketChannel.connect blocks until the handshake completes
  • Flutter ships a built-in WebSocket widget, so no package is needed
  • Closing the socket is unnecessary because the garbage collector handles it
  • Messages on channel.stream are already decoded into maps