| name | websockets-for-mobile |
| description | Design WebSocket servers and clients for mobile -- reconnection, heartbeats, backoff, and horizontal scale-out. Use when building realtime features over WebSockets. |
WebSockets for Mobile
Instructions
Mobile WebSockets live on networks that drop connections constantly (radio sleep, cell handoff, Wi-Fi switch). The protocol is simple; operating it reliably is not.
1. Connection Lifecycle
[open] -> [authenticate] -> [subscribe] -> [idle <-> messages] -> [close]
| ^
+------------------ reconnect with backoff -----------------------+
Authenticate inside the WebSocket, not via cookies. The client sends the access token in the first frame:
{ "type": "auth", "token": "eyJhbGci..." }
The server validates, replies { "type": "auth_ok", "session_id": "..." }, and only then accepts subscriptions. Reject and close with code 4401 on failure.
2. Heartbeats
TCP keepalive is too slow on mobile. Use application-level pings.
- Server sends a
ping every 20–30 s.
- Client responds with
pong within 10 s.
- Server closes the connection if two consecutive pongs are missed.
Clients should also ping the server if idle -- some NATs drop silent connections in ~60 s. WebSocket PING frames (opcode 0x9) work if your stack exposes them; otherwise use a JSON heartbeat message.
func (c *Conn) heartbeat(ctx context.Context) {
t := time.NewTicker(25 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done(): return
case <-t.C:
if err := c.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second)); err != nil {
c.Close(); return
}
}
}
}
3. Reconnection and Backoff
On disconnect, reconnect with exponential backoff + full jitter:
delay = min(cap, base * 2^attempt) * random(0, 1)
// base = 500ms, cap = 30s
Reset the attempt counter on a successful connect that lives ≥ 30 s; otherwise a flapping network will thrash.
iOS (Swift, URLSessionWebSocketTask):
actor WSClient {
private var task: URLSessionWebSocketTask?
private var attempt = 0
func connect() async {
while !Task.isCancelled {
do {
try await openAndRun()
attempt = 0
} catch {
let delay = min(30.0, 0.5 * pow(2.0, Double(attempt))) * Double.random(in: 0...1)
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
attempt += 1
}
}
}
}
Android (Kotlin, OkHttp WebSocket): identical algorithm; guard with Lifecycle so the loop stops on app background beyond a grace period.
4. Message Framing
Use a single JSON envelope with a type discriminator:
{ "type": "chat.message", "id": "01HX7...", "seq": 4812, "data": { "...": "..." } }
id is client-generated for outbound, server-generated for inbound; enables dedup.
seq is a monotonically increasing server counter per channel for ordering.
- Keep payloads small; large blobs go over HTTP, referenced by URL.
5. Resume / Catch-Up
On reconnect, the client tells the server the last seq it saw per subscription:
{ "type": "subscribe", "channel": "thread:thr_01HX7", "since_seq": 4812 }
Server replays missed messages up to a cap (e.g., 500 or 5 minutes). If the gap exceeds the cap, reply { "type": "reset", "reason": "history_too_old" }; client fetches state via HTTP.
6. Horizontal Scale-Out
WebSockets are stateful; a user's connection lives on one node. Fan-out patterns:
- Pub/Sub bus (Redis, NATS, Kafka): each node subscribes to channels its clients care about; incoming publishes fan out to matching local connections.
- Sharded router: route by
user_id hash so a user's multiple devices may end up on one node (easier presence) or different nodes (more balanced).
import Redis from "ioredis";
const sub = new Redis(); const pub = new Redis();
function onUserSubscribe(userId: string, ws: WebSocket) {
const channel = `user:${userId}`;
sub.subscribe(channel);
connections.get(channel).add(ws);
}
sub.on("message", (channel, data) => {
for (const ws of connections.get(channel) ?? []) ws.send(data);
});
function publishToUser(userId: string, payload: object) {
pub.publish(`user:${userId}`, JSON.stringify(payload));
}
Sticky sessions or consistent hashing at the load balancer keep the same client on the same node across reconnects when possible.
7. Backpressure
A slow client should not memory-leak the server. Bound the per-connection write buffer and drop or disconnect on overflow:
write_buffer_max = 1 MiB
if buffered > write_buffer_max: close(4008 "slow client")
8. Close Codes
Define a small set of codes:
1000 normal
4401 auth failed
4408 idle timeout
4409 superseded (another connection for same device took over)
4503 server overloaded -- client should back off longer
9. Mobile-Specific Concerns
- Background: iOS suspends WebSockets when backgrounded (except VoIP/background modes). On resume, treat as a fresh reconnect.
- Data use: some carriers meter heavily; honor
.cellular vs Wi-Fi by extending ping intervals on cellular.
- Battery: pinging every 5 s on cellular wakes the radio constantly. 25–30 s is a good default.
Checklist