skip to content

In a Flutter live-scores app using web_socket_channel, how do you reconnect after drops and app backgrounding without hammering the server or losing updates?

level: seniorimportance: must knowfreq 52%

answer

  1. a channel cannot be reopened
  2. new channel per attempt, same repository
  3. backoff with jitter and a cap
  4. close on hide, reconnect on show
  5. resubscribe, then fetch a snapshot

basics

~20 s

A WebSocketChannel cannot be reopened, so a repository creates a new one per attempt, detects drops via onDone or errors, retries with capped, jittered backoff, closes on backgrounding and reconnects on return, then resubscribes and fetches a snapshot.

solid answer

~40 s

The package has no reconnect: once `channel.stream` is done, that channel is finished. I put the socket behind a repository that exposes one long-lived broadcast stream of `ScoreUpdate`s, and on `onDone` or an error it creates a fresh `WebSocketChannel`, awaits `ready`, re-sends the subscribe message and pipes the new stream into the same output. Retries use exponential backoff with jitter and a cap, reset after a connection that stays healthy, and skip reconnecting when `closeCode` says the match is over. For the background, an `AppLifecycleListener` closes the socket in `onHide` and reconnects in `onShow`, since the OS may suspend the app and kill the connection anyway. After every reconnect I fetch the current score over REST, because updates sent while disconnected are gone.

code

dart · 74 lines
dart
import 'dart:async';
import 'dart:convert';
import 'dart:math';

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

class ScoresRepository {
  ScoresRepository(this._uri, this._fetchSnapshot);

  final Uri _uri;
  final Future<ScoreUpdate> Function() _fetchSnapshot;
  final _out = StreamController<ScoreUpdate>.broadcast();
  final _random = Random();
  WebSocketChannel? _channel;
  Timer? _retry;
  int _attempt = 0;
  bool _active = false;

  Stream<ScoreUpdate> get updates => _out.stream;

  void start() {
    _active = true;
    _attempt = 0;
    _connect();
  }

  Future<void> stop() async {
    _active = false;
    _retry?.cancel();
    await _channel?.sink.close(status.normalClosure);
    _channel = null;
  }

  Future<void> _connect() async {
    final channel = WebSocketChannel.connect(_uri);
    _channel = channel;
    try {
      await channel.ready;
    } on Object {
      _scheduleRetry();
      return;
    }
    if (!_active || _channel != channel) {
      await channel.sink.close(status.normalClosure);
      return;
    }
    channel.sink.add(jsonEncode({'action': 'subscribe'}));
    try {
      _out.add(await _fetchSnapshot());
    } on Object {
      // keep the live feed; the next update corrects the score
    }
    channel.stream.listen(
      (raw) {
        _attempt = 0;
        _out.add(ScoreUpdate.fromJson(jsonDecode(raw as String) as Map<String, dynamic>));
      },
      onError: (Object _) {},
      onDone: () {
        if (channel.closeCode == 4001) return; // match finished
        _scheduleRetry();
      },
    );
  }

  void _scheduleRetry() {
    if (!_active) return;
    final capped = min(30, 1 << min(_attempt, 5));
    final delay = Duration(milliseconds: _random.nextInt(capped * 1000) + 500);
    _attempt++;
    _retry = Timer(delay, _connect);
  }
}

go deeper

for a junior

Recall that a dropped channel cannot be reused, so reconnecting means creating a new WebSocketChannel and sending the subscribe message again.

for a middle

Explain the reconnect loop: detect the drop via onDone or errors, wait with growing delays, reconnect, resubscribe, and expose one stable stream to the UI.

for a senior

Justify jitter, caps and reset rules, closing on hide and reconnecting on show, and how snapshots or sequence numbers prevent missed updates.

for a principal

Weigh the load a mass reconnect puts on the backend, when push notifications replace sockets, and what freshness guarantees the product actually needs.

## Why reconnection is your job `web_socket_channel` models one connection per channel. When the connection ends, the channel's stream is done and cannot be reused, and the package never reconnects by itself. Anything that should survive a dropped connection (a live-scores feed, a chat, a price ticker) needs a layer above the channel that owns the **policy**: when to reconnect, how fast, and how to catch up. ## The repository shape The screen should not know that sockets come and go. A repository hides that: - it exposes **one long-lived stream**, for example from a broadcast `StreamController<ScoreUpdate>`, that widgets or a state object listen to for as long as they like; - it holds the **current channel** privately and replaces it on each attempt; - it publishes a **connection state** (connecting, live, reconnecting, offline) so the UI can show a banner instead of a silently stale score. Each attempt runs the same sequence: 1. `WebSocketChannel.connect(uri)` for a **new** channel. 2. `await channel.ready`; on failure, schedule the next attempt. 3. Send the **subscribe** message again: the server has no memory of the previous connection's subscriptions. 4. **Fetch a snapshot** of the current score over HTTP, because anything sent while disconnected was lost. 5. Forward messages from the new `channel.stream` into the repository's output; on `onDone` or `onError`, go back to step 1 after a delay. ## Backoff without a thundering herd When a server restarts, every client drops at once. If they all retry immediately, and then again every second, the recovering server is flooded. The usual policy is: - **exponential delays** (1 s, 2 s, 4 s...) with a **cap** such as 30-60 s; - **jitter**, a random spread on each delay, so clients do not retry in lockstep; - a **reset** of the delay after a connection has stayed healthy for a while, not merely after `ready`, so a server that accepts and immediately drops does not keep clients at the shortest delay; - **no retry** when the close was deliberate: a `closeCode` your server defines for "match finished" ends the loop, and one for "token expired" refreshes credentials first. The mathematics of backoff and retry budgets are shared with HTTP retries; the socket-specific parts are the new-channel-per-attempt rule and the close-code checks. ## When the app goes to the background Mobile operating systems do not keep a backgrounded app's sockets alive indefinitely: the app may be suspended, and the connection then dies without a clean close. Rather than fight that, make it explicit: | Lifecycle moment | Action | |---|---| | `AppLifecycleListener.onHide` | close with `status.normalClosure`, stop the reconnect timer | | `AppLifecycleListener.onShow` | reset backoff, reconnect, resubscribe, fetch a snapshot | | app killed while hidden | nothing to do; the next launch starts fresh | Closing on hide also saves battery and server connections for users who switched away hours ago. If users must be told about a goal while the app is closed, that is a job for push notifications, not for a socket kept alive in the background. ## Not losing updates A socket gives no replay: messages sent while the client was away are gone. Two techniques close the gap: - **Snapshot on reconnect**: fetch the current state over HTTP after each successful `ready`, then apply live updates on top. - **Sequence numbers**: if the server stamps updates, the client can detect a gap and fetch only what it missed, or discard updates older than the snapshot. ## Testing the loop Reconnect logic is timing-heavy, so write it to be testable: inject the function that creates a channel and the function that fetches a snapshot, and drive time with a fake clock in unit tests. Useful cases: - the first `ready` fails, and the next attempt happens after the expected delay range; - a server closes with the "match finished" code, and no further attempt is made; - `stop()` is called while a connect is in flight, and the late channel is closed rather than leaked; - after a reconnect, the subscribe message is sent again and the snapshot is published before live updates. ## Checklist - one repository, one output stream, many short-lived channels; - every channel closed when replaced or when the repository is disposed; - backoff with jitter, a cap and a reset rule; - lifecycle-driven close and reconnect; - snapshot or gap detection after every reconnect.

  • Why reset the backoff on the first received message rather than when ready completes?
    A server under stress may accept the upgrade and then drop the connection at once. Resetting on `ready` would keep every client retrying at the shortest delay against a struggling server. Resetting only after real traffic, or after the connection has stayed up for a while, lets delays grow when connections are not actually healthy.
  • Why not keep the socket open while the app is in the background?
    Because the OS may suspend the app and the connection then dies without a clean close, so you cannot rely on it anyway, while it still costs battery and a server connection. Close on hide, reconnect and fetch a snapshot on show, and use push notifications for anything users must hear about while the app is not visible.
  • How does a widget keep receiving scores when the underlying channel is replaced?
    It never listens to a channel directly. The repository exposes one broadcast stream that outlives every channel; each new channel's messages are forwarded into it. The widget or state object subscribes once to the repository and sees a continuous feed, plus a connection-state value for banners.

The repository is a radio presenter with a phone line to a stadium reporter: when the call drops they redial, waiting a little longer each time, and before going back on air they ask for the current score so listeners do not miss a goal.

saying these in an interview costs you the question

  • web_socket_channel reconnects automatically after the network returns
  • You can call connect again on the same channel to reopen it
  • Retrying every second forever is fine because sockets are cheap
  • A socket keeps delivering updates while the app is suspended in the background
  • After reconnecting, the server replays everything sent while the client was away