Live order tracking is one of those features that demos in five minutes and takes three weeks to make trustworthy. The socket part is easy. Everything that happens when the socket is not fine is the actual work.
Here is what I learned shipping real-time tracking for a delivery fleet, where the client is a Flutter app in a courier's pocket, moving between cell towers, lifts and basements.
The socket does not tell you it died
The first assumption to drop: that you will be notified when the connection breaks.
A clean close fires onDone. A refused connection fires onError. But the common mobile failure — the network vanishes mid-session — often fires neither, for minutes. TCP will eventually time out, but "eventually" here can be long past the point where your user has decided the app is broken.
So you cannot wait to be told. You have to ask.
Heartbeats, at the application layer
TCP keepalive exists but its default interval is measured in hours and you do not control it from Dart. Send your own ping and require an answer:
void _startHeartbeat() {
_heartbeat = Timer.periodic(const Duration(seconds: 20), (_) {
if (_awaitingPong) {
// The previous ping was never answered — the link is gone.
_reconnect(reason: 'pong timeout');
return;
}
_awaitingPong = true;
_channel?.sink.add(jsonEncode({'type': 'ping'}));
});
}The server answers {"type": "pong"} and the client clears _awaitingPong. Twenty seconds is a reasonable compromise: short enough that a user notices nothing, long enough that you are not burning a phone's radio.
On the Django side with Channels, that is four lines:
async def receive_json(self, content):
if content.get("type") == "ping":
await self.send_json({"type": "pong"})
return
await self.handle(content)Reconnect with backoff and jitter
When your backend restarts, every connected client notices at the same instant. If they all retry after one second, you have built a client-side DDoS against your own API — right when it is least able to cope.
Exponential backoff alone does not fix this, because synchronised clients back off in synchrony. You need randomness:
Duration _backoff(int attempt) {
const base = Duration(seconds: 1);
const cap = Duration(seconds: 30);
final exponential = base * math.pow(2, attempt).toInt();
final bounded = exponential > cap ? cap : exponential;
// Full jitter: anywhere in [0, bounded], not bounded exactly.
return Duration(
milliseconds: _random.nextInt(bounded.inMilliseconds + 1),
);
}Full jitter — picking uniformly from zero up to the bound rather than the bound itself — spreads a reconnect storm across the whole window. It reconnects individual clients slightly slower on average and the fleet dramatically faster overall.
Reset attempt to zero only after a connection has stayed up for a while, not the moment it opens. Otherwise a connection that opens and immediately fails will retry at full speed forever.
Mobile kills your socket in the background
This one costs people days. On both Android and iOS, a socket held by a backgrounded app will be torn down — and depending on the OS version, you may get no callback at all. Your app returns to the foreground, the UI looks connected, and no data ever arrives.
Treat resume as a reconnect:
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_ensureConnected(); // cheap no-op if the socket really is alive
}
}The part everyone skips: resume
Reconnecting is not enough. If a courier's phone was offline for forty seconds, three status changes happened that the client never saw. Reconnect naively and the UI silently keeps showing a stale state.
Give every event a monotonic sequence number, remember the last one you processed, and send it when you reconnect:
_channel!.sink.add(jsonEncode({
'type': 'subscribe',
'order_id': orderId,
'since': _lastSeq, // null on a first connection
}));The server replays anything newer before resuming the live stream:
async def subscribe(self, order_id, since):
if since is not None:
missed = await self.events_after(order_id, since)
for event in missed:
await self.send_json(event)
await self.channel_layer.group_add(f"order.{order_id}", self.channel_name)Note the order: replay the backlog before joining the group. Join first and a live event can overtake the replay, and the client applies state out of order.
Keeping a short event log — we kept an hour — is what turns "reconnected" into "caught up". It is the difference between a feature users trust and one they refresh manually because they have learned not to.
A checklist
If you are about to ship a socket to real phones:
- Application-level ping with a pong deadline, not TCP keepalive.
- Exponential backoff with full jitter, and a cap.
- Reset the attempt counter on a stable connection, not on open.
- Reconnect on
AppLifecycleState.resumed. - Sequence numbers on every event,
sinceon every subscribe. - Replay before joining the broadcast group.
- Show connection state in the UI. Users forgive a stale value they can see is stale.
None of this is about WebSockets being hard. It is about mobile networks being hostile, and a socket being the one part of your stack that notices.