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?
answer
- a channel cannot be reopened
- new channel per attempt, same repository
- backoff with jitter and a cap
- close on hide, reconnect on show
- resubscribe, then fetch a snapshot
basics
~20 sA 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 sThe 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 linesimport '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
Recall that a dropped channel cannot be reused, so reconnecting means creating a new WebSocketChannel and sending the subscribe message again.
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.
Justify jitter, caps and reset rules, closing on hide and reconnecting on show, and how snapshots or sequence numbers prevent missed updates.
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