Building a Real-Time Chat Feature for Mobile

For a marketplace app's buyer-seller messaging feature, we started with a straightforward WebSocket connection to a Node.js server broadcasting through Redis pub/sub. That worked fine in the office on Wi-Fi and fell apart in field testing — users on the subway or in buildings with poor signal saw messages arrive out of order or, worse, silently drop when the socket disconnected without the client noticing for up to 30 seconds.
The fix required treating the network as unreliable by default rather than as an edge case. We added a heartbeat ping every 15 seconds with a 5-second timeout to detect dead connections faster than TCP's own timeout would, paired with exponential backoff reconnection — starting at 1s, capping at 30s — so a flaky connection didn't hammer the server with reconnect attempts.
Message ordering and delivery guarantees moved to the client. Each message gets a client-generated UUID and a locally-assigned sequence number before it's sent; the server echoes both back on acknowledgment. This let us build an optimistic UI — the message appears instantly with a 'sending' state — while reconciling against the server's canonical order once the ack arrives, instead of waiting on round-trip latency for every send.
Offline queuing turned out to be the highest-value feature for actual usage patterns. Messages composed while offline are persisted to a local SQLite outbox table and flushed in order once connectivity returns, rather than failing outright. This alone eliminated the majority of 'my message didn't send' support tickets, since a huge share of usage was people messaging while their signal cut in and out.
For push notifications tied to new messages, we deliberately kept the WebSocket and push channels separate rather than trying to unify them — the socket handles in-app real-time delivery while the app is foregrounded, and FCM/APNs handle background delivery, with the client deduplicating by message ID when both arrive close together. Trying to route everything through one channel early on caused duplicate notifications that took longer to debug than just keeping the paths separate.

