Building Group Chat in Mobile Apps
Group chat is harder than private not by multiples but by orders of magnitude. Private chat has two participants — all events sync through one WebSocket to one conversation. Group chat with 200 participants requires the server to fanout each message to 199 connections, calculate unread correctly for each, not crush Redis under load, and work correctly when part of participants are offline. This is already an architecture problem, not just UI.
Server Architecture: Fanout and Presences
Message Distribution
Most painful part — delivering message to all group members. Synchronous fanout ("sent → pushed to all connections → responded to client") doesn't scale: with group of 500 people, iterating active connections takes tens of milliseconds, and if there are multiple WebSocket servers — participant connections spread across different nodes.
Correct scheme: client → WebSocket server → queue (Redis Pub/Sub or Kafka topic per group) → each WebSocket server reads from its queue and delivers to online participants → for offline participants — push notification queue.
For groups up to 100 members, Redis Pub/Sub with channel-per-group works well. For larger — Kafka or NATS JetStream with consumer groups.
Unread Message Counts
Classic mistake — store last_read_message_id in group_members table and on each request count SELECT COUNT(*) WHERE id > last_read_message_id. On group with thousands of messages and hundreds of members, this kills the database.
Working approach: Redis Hash unread:{user_id}:{group_id} → increment on each new message in group, reset when opening chat. Total badge — HVALS unread:{user_id} and sum on client. On Redis restart — recalculate from PostgreSQL as fallback.
Roles and Permissions
Schema: owner, admin, member. Permissions granularly: can_send_messages, can_add_members, can_remove_members, can_edit_group_info. Stored in group_members.role + JSON field permissions for custom overrides. Check at API middleware level before action execution.
Mobile UI: What's Technically Hard
Member List and Mentions
On @ input — popup with member filtering. On iOS: UITextView + custom UIView-overlay positioned above keyboard through KeyboardLayoutGuide. On selecting member — insert attributed string with NSAttributedString and custom NSTextAttachment or just colored range.
In Jetpack Compose: BasicTextField with custom VisualTransformation for mention coloring + Popup with LazyColumn for dropdown. @ trigger — through TextFieldValue.text.lastIndexOf('@') with 200ms debounce.
On backend when saving message — parse mentions with regex, create message_mentions[] records, separate push notification to mentioned members even if they muted group.
Media and Files in Group
Photos, videos, documents — upload through presigned S3 URL like in private chat, but with additional quota check (storage limit per group or per user). Group media gallery — separate screen with UICollectionView/LazyVerticalGrid, query from messages table by type IN ('image','video') AND group_id = ? with pagination.
Link preview: on server when receiving message with URL — async job (Sidekiq/Celery) parses Open Graph metadata, caches in Redis for 24h, client gets preview data in message.updated event.
Typing Indicator
WebSocket event typing.start / typing.stop from client → server broadcasts to group with user_id typing → clients show "Ivan is typing...". Problem: with 20 people typing simultaneously, UX breaks. Limitation: show max 3 names, then "and N more people are typing". Timeout: if typing.stop doesn't arrive — auto-hide after 5 seconds.
Offline and Sync
Group chat requires local database. SQLite through SQLCipher (encryption) — schema: groups, messages, group_members. On app startup — sync with server: request all groups with last_synced_at, then for each group — messages after last message_id. Conflicts on simultaneous edit — Last Write Wins by updated_at.
On iOS — GRDB.swift over SQLite, on Android — Room with Flow subscription for reactive UI updates.
Common Self-Implementation Mistakes
-
N+1 loading group list: separate query
last_messagefor each group. Solution: JOIN with subquery or denormalizedlast_message_previewfield. -
Push on all messages ignoring mute: member muted group but gets push. Check
group_members.notifications_mutedon server before sending FCM/APNs. - Delete member without cleanup: after kick user still gets WebSocket events if connection not closed. Need forced disconnect through signal on WebSocket server.
- No optimistic updates: message appears in UI only after server response. Right — show immediately with "sending" status, update/rollback on response.
Timeframe and Scope
Basic group chat (group creation, admin/member roles, messages with pagination, push) — 3-4 weeks. Full functionality (media, mentions, link preview, offline sync, storage quotas, gallery) — 2-3 months. For Flutter project — roughly 30% faster due to unified UI layer.
Cost calculated after detailed spec: platforms, group size (member limit), encryption and offline requirements significantly affect architectural decisions.







