Opening a WebSocket is one line of code. Keeping it open on a phone is the hard part. The user walks into a lift, the phone switches from Wi-Fi to mobile data, the app goes to the background, the access token expires. The socket dies, sometimes with an error and sometimes without a word.
So every realtime feature ends up with the same code around the socket: reconnect, but not in a tight loop. Get a fresh token before reconnecting, because the old one may have expired. Notice a connection that is dead but still looks open. Re-subscribe to the topics. Do something sensible with messages sent while offline. And stop for good when the server says "you are not allowed", instead of retrying forever.
resilient_ws packages that code. It has two realtime clients for Dart and Flutter that keep working on bad mobile networks: a WebSocket client and a Server-Sent Events client. Both reconnect on their own, fetch a fresh token for every connection, and stop cleanly when told to. Both work on every platform, including the web.
What it does
ResilientWebSocket
- States.
connecting,open,reconnectingandclosed, available as a stream and as a getter. - Backoff. Exponential backoff with jitter and a cap: 1 s doubling to 30 s by default. The backoff resets only after a connection has stayed up for
stableAfter(10 s by default), so a server that accepts and then drops connections does not get hammered. - Fresh tokens. The token provider runs before every connection attempt. There are three ways to send the token: an HTTP header (
dart:ioonly), a query parameter, or a first message. - Heartbeat. An application-level ping. If no frame arrives within
timeoutof a ping, the client reconnects. Any inbound frame counts as proof of life, and pongs are not passed on tomessages. - Outbox. A bounded buffer for messages sent while disconnected, with a
dropOldestordropNewestoverflow policy. Buffered messages are flushed in order after a reconnect. - Subscriptions. Topic subscriptions are re-sent after every reconnect. The subscribe and unsubscribe frames come from a pluggable
SubscriptionCodec. - Typed messages. Messages go through a pluggable
MessageCodec<T>, withJsonMessageCodecincluded. - Permanent close codes. Close codes that mean "stop" (1008, 4401 and 4403 by default, configurable) end the client instead of starting a retry loop.
- Network awareness. An optional
Stream<bool>of network availability pauses reconnects while offline and reconnects at once when the network returns. - Clean close.
close()sends a close frame, cancels every timer, and never triggers a reconnect.
SseClient
The parser follows the WHATWG HTML "Server-sent events" section: the event, data (multi-line), id and retry fields, comments and heartbeats, a leading BOM, and CRLF, CR and LF line endings, including a CRLF split across chunks. An event is dispatched only on a blank line.
The client reconnects automatically, sends Last-Event-ID, and honours the server's retry: field. Consecutive failures double the wait, up to maxRetry. The token provider runs before every request. HTTP 204 stops the client, as the spec says. By default 4xx statuses stop it too, while 5xx, 408 and 429 are retried, and both rules are configurable. An optional idleTimeout reconnects when a connection goes silent, and the http.Client can be injected.
How to use it
Add it from GitHub:
dependencies: resilient_ws: git: https://github.com/AbdeldjalilChougui/resilient_ws A typed WebSocket client:
import 'package:resilient_ws/resilient_ws.dart'; class Tick { Tick(this.topic, this.value); factory Tick.fromJson(Object? json) { final map = json! as Map<String, Object?>; return Tick(map['topic']! as String, map['value']! as num); } final String topic; final num value; Map<String, Object?> toJson() => {'topic': topic, 'value': value}; } Future<String?> readAccessToken() async => 'token'; // your auth layer Future<void> main() async { final ws = ResilientWebSocket<Tick>( url: Uri.parse('wss://api.example.com/ws'), codec: JsonMessageCodec<Tick>( fromJson: Tick.fromJson, toJson: (t) => t.toJson(), ), tokenProvider: readAccessToken, // called before every connect tokenAuth: const TokenAuth.queryParameter(name: 'token'), heartbeat: const Heartbeat( interval: Duration(seconds: 25), timeout: Duration(seconds: 10), ), outboxCapacity: 50, overflowPolicy: OverflowPolicy.dropOldest, permanentCloseCodes: {1008, 4001, 4003}, ); ws.states.listen((s) => print('state: ${s.name}')); ws.messages.listen((tick) => print('${tick.topic}: ${tick.value}')); ws.errors.listen((e) { if (e is PermanentCloseException) print('server said stop: ${e.code}'); }); ws.subscribe('prices'); // re-sent after every reconnect ws.connect(); ws.send(Tick('hello', 1)); // buffered until the socket is open await Future<void>.delayed(const Duration(seconds: 30)); await ws.close(); // no reconnect follows } The default subscription frames are {"op":"sub","topic":"prices"} and {"op":"unsub","topic":"prices"}. If your server speaks another protocol, pass a SubscriptionCodec.from(subscribe: ..., unsubscribe: ...). If it expects the token in a first message, use TokenAuth.firstMessage; the client then sends it before the subscriptions and before any buffered messages.
Server-Sent Events:
import 'package:resilient_ws/resilient_ws.dart'; Future<void> main() async { final sse = SseClient( url: Uri.parse('https://api.example.com/events'), tokenProvider: () async => 'token', // "Authorization: Bearer token" retry: const Duration(seconds: 3), // until the server sends "retry:" idleTimeout: const Duration(seconds: 60), // server heartbeats every 15 s ); sse.events.listen((e) => print('${e.type} #${e.lastEventId}: ${e.data}')); sse.states.listen((s) => print('state: ${s.name}')); sse.connect(); await Future<void>.delayed(const Duration(minutes: 1)); await sse.close(); } The parser can also be used by itself, as a stream transformer: SseParser.transformer().
Behaviour worth knowing
The README documents the exact rules, which matters for this kind of code:
- Connection order. On every connect the client sends the first-message token (if used), then every subscription, then the buffered messages.
- Backoff formula. The delay for attempt
nismin(max, initial * multiplier^n), minus a random share of up tojitter(0.2 by default). Jitter only shortens a delay, somaxis a hard cap. - Failures. A throwing token provider, a handshake that fails or takes longer than
connectTimeout, or a dropped connection are all retried.maxAttempts, off by default, stops the client after that many failed attempts in a row. - Stale connections. Every connection attempt has a generation number, so frames, errors and close events from a replaced connection are ignored.
reconnect()forces a fresh connection, which is useful when an app returns to the foreground. - SSE ids. An
id:field counts only once the blank line after it arrives, as the spec says, so a half-received event never movesLast-Event-ID.
Things to know
- Header auth is
dart:ioonly. Browsers do not let a WebSocket set headers. On the web,TokenAuth.headermakes the connection attempt fail withUnsupportedError. UseTokenAuth.queryParameterorTokenAuth.firstMessageinstead. - SSE on the web. The client uses
package:http, not the browser'sEventSource, so it can sendAuthorizationandLast-Event-IDheaders. Custom headers trigger a CORS preflight, so the server must allowauthorization,last-event-idandcache-control. A proxy or dev server that buffers or compressestext/event-streamcan still delay events, and that is outside the client's control. A browser test in the repository checks that events arrive one by one in Chrome and that the client reconnects withLast-Event-ID. - The heartbeat is application-level. The server must answer your ping frame, or send any traffic within
timeout. WebSocket protocol-level pings are not configured. - Handshake rejections cannot be told apart. A server that rejects the upgrade with HTTP 401 or 403 looks like any other failed handshake to
web_socket_channel, so it is retried with backoff, with a fresh token each time. UsemaxAttemptsto bound it, or have the server accept the upgrade and close with an auth close code such as 4401. - Undispatched SSE data is discarded. An event cut off mid-stream is thrown away, as the spec requires.
Why I built it
The reconnect logic around a socket is the same in every app, and it is easy to get subtly wrong. A retry loop without jitter. A token read once at startup. A heartbeat that fires on a connection that was already replaced. A close() that is followed by one last reconnect.
I wanted those rules written once, with the behaviour documented and the SSE parser tested against the examples of the spec itself. I also wanted WebSocket and SSE side by side, with the same ideas for tokens, states and retries, because the choice between them often depends on the server and not on the app.
Get the code
The source, the behaviour notes and the tests are on GitHub: https://github.com/AbdeldjalilChougui/resilient_ws. It's MIT licensed. Issues and pull requests are welcome.
Originally published at abdeldjalilchougui.biz.
