Building One-on-One Chat in Mobile Apps
Private chat between two users — not just "a list of messages". The problem usually isn't display but state synchronization: user on iOS sends message, Android buddy sees it 3 seconds later with a duplicate, because WebSocket died and client resent through REST. Or history doesn't load on slow connection because pagination is done through OFFSET and timeout hits at the 500th page.
Transport: WebSocket or SSE
For one-on-one chat, WebSocket connection is enough. On iOS — URLSessionWebSocketTask (native, no dependencies) or Starscream if you need more flexible heartbeat handling. On Android — OkHttp WebSocket from the box through Retrofit ecosystem or Ktor WebSocket Client for Kotlin Multiplatform.
Critical moment — reconnection. WebSocket breaks on network switch (Wi-Fi → 4G), on iOS background lock, on aggressive Doze Mode on Android. You need exponential backoff: first reconnect after 1s, then 2s, 4s, 8s, cap 30s. After reconnection — request missed messages with last_message_id so you don't miss what came while connection was down.
Firebase Realtime Database or Firestore — alternative to custom WebSocket server for small projects. Quick to start, but for complex business logic (moderation, server-side encryption), you hit Cloud Functions limitation.
How We Build Chat
Data Model
Message: id, conversation_id, sender_id, body, type (text/image/file/system), status (sent/delivered/read), client_message_id (UUID generated on client), created_at. client_message_id — idempotency key: if client resends after timeout, server doesn't create duplicate.
Conversation: id, participant_ids[], last_message_id, last_message_at. Index: (participant_ids, last_message_at DESC) — so user's dialog list queries fast.
History Pagination
Cursor-based by (created_at, id): load latest N messages, on scroll up send before_cursor. This works stable with fast new message insertion — OFFSET "floats" when new records appear between pages.
On iOS message list — UICollectionView with inverted layout (new at bottom): transform = CGAffineTransform(scaleX: 1, y: -1) on collectionView and each cell. When adding new message insertItems with scrollToItem — not reloadData which causes flicker. On Android — LazyColumn(reverseLayout: true) in Jetpack Compose.
Delivery and Read Statuses
Delivered: server confirms message receipt (ack in WebSocket protocol) and updates status. Read: receiving client sends read receipt when conversation is open and message is visible on screen (through Intersection Observer on web or through UICollectionView.indexPathsForVisibleItems on iOS).
Status UI: one check (sent), two gray (delivered), two blue (read) — classic. Update through local update in DiffableDataSource without network request.
Encryption
For basic end-to-end: Signal Protocol through libsignal-client (Rust library with bindings for iOS/Android). Keys stored in Keychain (iOS) / Android Keystore. Server sees only encrypted blob — even if database is compromised, conversation can't be read.
If E2E not mandatory — transport encryption (TLS 1.3) + server-side encryption at rest enough for most cases.
Push Notifications When Chat is Closed
FCM (Android) and APNs (iOS). On iOS need UNUserNotificationCenter + UNNotificationServiceExtension if you need to show preview of encrypted message: Extension decrypts payload before display without sending keys to server.
Deep link on push tap — opens specific conversation: myapp://chat/conversation/{id}. Implemented through UIApplicationDelegate.application(_:open:options:) or onOpenURL in SwiftUI.
Stages and Timeframe
Basic chat (WebSocket, history with pagination, statuses, push) — 5 working days on one platform. Adding media attachments, E2E encryption, typing indicator (typing indicator through WebSocket event) — another 3-5 days. Flutter — slightly faster due to unified codebase. Cost — after analyzing requirements and target platforms.







