NewHuxly MCP — Connect Claude, Cursor & Codex.Learn more
How to Build Real-Time Chat Into a Mobile App
Back to Blog
GuideSep 13, 202613 min read

How to Build Real-Time Chat Into a Mobile App

Contents

A reliable mobile chat needs a message database, a private real-time channel, delivery states, pagination, notifications, and strict membership rules. The database remains the source of truth.

This guide uses Supabase as the backend example because it combines Postgres, authentication, storage, and Realtime. The same design works with another backend if it provides equivalent database, authorization, and event capabilities.

Key takeaways:

Save messages before broadcasting them, authorize every conversation on the server, use temporary client IDs for optimistic sending, separate durable messages from temporary typing events, and reconnect by fetching missed database records instead of trusting the socket alone.

Decide what kind of chat you are building

Start with the smallest conversation model that supports the product. Direct messages, group rooms, marketplace support threads, and public communities have different permission and moderation needs.

Write down these decisions before creating tables:

  • Can a conversation have two members or many?
  • Who may create a room or invite another user?
  • Can members leave, mute, block, or report?
  • Are messages editable or deletable?
  • Will the app support images, files, audio, or only text?
  • Do users need read receipts, typing indicators, or online status?
  • How long should messages remain available?

A direct messaging MVP usually needs text messages, conversation history, unread counts, push notifications, blocking, and reporting. Typing indicators and reactions can wait until the main delivery path is stable.

Use the database as the source of truth

A WebSocket event is temporary. The database record is durable. If you broadcast a message before saving it, recipients may briefly see content that disappears after refresh, or they may miss it completely during a connection drop.

A safer path is:

text
Sender taps Send
  -> app creates a local pending message
  -> authenticated backend validates membership
  -> database inserts the message
  -> committed message is broadcast to the room
  -> sender replaces the pending item with the saved record
  -> recipients append the saved record

Supabase Realtime provides three different features: Broadcast sends low-latency events, Presence synchronizes temporary user state, and Postgres Changes listens for database changes. The Supabase Realtime overview explains the role of each one.

Realtime featureGood use in chatShould it store history?
BroadcastNew-message events, typing, reactions, and custom room eventsNo
PresenceOnline or currently active membersNo
Postgres ChangesListening directly for inserted or updated message rowsThe database stores the row

For a modest first version, listening for inserted message rows can be simple. For larger rooms or heavier traffic, database-triggered Broadcast gives you more control over event topics and payloads. Supabase notes that Postgres Changes checks each event against each subscriber and processes changes in order, so review its Postgres Changes scaling guidance before using it for a high-volume room.

Design the conversation schema

Keep room membership separate from the conversation itself. That makes group chat, unread state, roles, and member removal easier to manage.

TableImportant fields
conversationsid, type, title, created_by, created_at, last_message_at
conversation_membersconversation_id, user_id, role, joined_at, last_read_message_id, muted_until
messagesid, conversation_id, sender_id, client_id, body, kind, reply_to_id, created_at, edited_at, deleted_at
message_attachmentsid, message_id, storage_path, mime_type, size_bytes, width, height
reportsid, reporter_id, message_id, reason, created_at, status

Use a generated server timestamp for message ordering. A phone's clock can be wrong or intentionally changed.

The client_id is generated by the sender before upload. If a retry reaches the backend twice, a unique constraint on the sender and client ID can return the existing message instead of creating a duplicate.

Do not place a growing array of messages inside the conversation row. Individual rows are easier to paginate, authorize, search, moderate, and update.

Enforce membership in the backend and database

The client may send a conversation ID, but it cannot decide whether the current user belongs to that conversation. Derive the user from the verified session and check the membership table.

Authorization should cover:

OperationRequired check
Open conversationUser has an active membership row
List messagesUser belongs to the conversation
Send messageUser belongs, is not blocked, and room allows posting
Edit messageCurrent user is the sender and edit window allows it
Delete messageCurrent user is the sender or an authorized moderator
Add memberCurrent user has the required room role
Read attachmentUser belongs to the related conversation

If the mobile app connects directly to Supabase, enable Row Level Security on every exposed chat table. The policy should compare auth.uid() with conversation membership, not with a user ID supplied in the request.

The same rule applies to real-time channels. Supabase's Realtime Authorization guide uses RLS policies on realtime.messages to control who may receive Broadcast and Presence events and who may send them. Private channels must be configured as private in the client.

For the supporting setup, see how to connect Supabase to a mobile app and how to add user authentication.

Build a predictable send flow

The composer should not freeze while the network request runs. Add the user's message to the list immediately with a local status, then reconcile it with the saved row.

Message stateWhat the UI should show
PendingMessage bubble appears with a subtle progress indicator
SentBackend accepted and stored the message
DeliveredOptional signal that another device received the event
ReadOne or more recipients advanced their read position
FailedRetry action remains attached to the original text

Use one client-generated ID for the original attempt and every retry. Do not create a fresh optimistic bubble each time the user taps Retry.

Validate message length and allowed content on both sides. Client validation gives immediate feedback. Server validation prevents a modified app from bypassing the rules.

When the insert succeeds, replace the local pending object with the server record. When the real-time event for the same message arrives, deduplicate by database ID or client ID.

Load history with cursor pagination

Do not download an entire conversation whenever the screen opens. Fetch the newest page, then load older messages when the user scrolls upward.

A stable cursor can use created_at plus id:

text
created_at < oldest_created_at
or
created_at = oldest_created_at and id < oldest_id

Using both values avoids ambiguity when several messages share the same timestamp. An indexed query on conversation ID, creation time, and ID keeps pagination predictable.

When older messages are inserted above the visible list, preserve the user's scroll position. A sudden jump makes long conversations difficult to read.

Cache a small recent page for fast reopening, but refresh from the server after reconnecting. The local cache improves startup and should not override a server-side deletion, edit, or membership change.

Reconnect without losing messages

Mobile connections change frequently. A user may move between Wi-Fi and cellular data, lock the phone, enter a tunnel, or leave the app in the background.

Treat the socket as a live update channel, not a complete delivery ledger. On reconnect:

  1. Reauthenticate if the session changed.
  2. Rejoin only the conversations currently needed.
  3. Fetch messages created after the newest confirmed local record.
  4. Merge and deduplicate the result.
  5. Retry unsent local messages according to your policy.
  6. Refresh membership and unread state.

Use connection states such as connecting, connected, reconnecting, and offline. A small offline label is more useful than silently accepting messages that cannot leave the device.

Never retry a failed send forever. Use bounded retries and let the user decide whether to try again after a permanent validation or permission error.

Add typing indicators and online status carefully

Typing and presence are temporary signals. They should not create database rows for every keystroke or heartbeat.

Broadcast a typing event when the user starts typing, throttle repeated events, and send a stop event after inactivity or submission. Recipients should also clear the indicator after a short local timeout because the stop event may be lost.

Presence can show that a user has an active connection, but it does not prove they are looking at a particular message. Label it "online" or "active" according to what you actually track.

Supabase describes Presence as synchronized state for active participants in its Realtime documentation. Do not use presence as a security decision. Membership and permissions still come from durable backend data.

Track read state without updating every message

For most apps, store one read position per member rather than a separate read row for every message. Update last_read_message_id or a read timestamp when the conversation is visible and the newest message has been displayed.

This supports:

  • Unread counts in the conversation list.
  • A read marker in direct messages.
  • "Read by" details for small groups.
  • Badge counts across devices.

Large groups may not need individual read receipts. Showing hundreds of readers creates extra writes and little user value.

Do not mark a message read simply because a push notification arrived. The user should open the conversation or visibly view the message according to your product rule.

Support attachments as a separate upload flow

Upload the file before creating the final message, or create a draft attachment record with a controlled status. Store the binary in object storage, not inside the message table.

The attachment path should be generated or validated by the backend. Use private storage for conversation files and grant access only to active members. Validate file type, size, and ownership on the server.

Show upload progress and allow cancellation. If the upload succeeds but message creation fails, mark the object for cleanup. If the message succeeds but the client misses the response, the client ID prevents a duplicate on retry.

Send push notifications when recipients are offline

Realtime events only help while the app maintains a connection. Push notifications alert users when the app is closed or backgrounded.

After a message is committed, your backend can identify eligible recipients, respect mute and block settings, and send a notification without exposing the full private message when privacy is important.

Expo's Notifications documentation covers obtaining push tokens and receiving or responding to notifications. Keep device tokens in a backend table tied to the authenticated user, platform, and installation.

A notification should include a safe conversation identifier for deep linking. When tapped, the app must still verify membership and fetch the message from the server. Notification data is not authorization.

Avoid notifying the sender's other active device unless the product needs it. Suppress notifications while the recipient is already viewing that conversation when your backend has a trustworthy activity signal.

Add blocking, reporting, and moderation

Any user-to-user chat needs abuse controls before launch. At minimum, support blocking and reporting, and provide an internal way to review reports.

Blocking should affect message sends, room discovery, notifications, and presence visibility according to a written product rule. Do not implement it as a client-only hidden row.

Moderation controls may include:

  • Message length and sending rate limits.
  • Link or attachment restrictions for new accounts.
  • Automated text or image screening where appropriate.
  • Manual report review.
  • Account warnings, suspension, and appeal handling.
  • Audit records for moderator actions.

Preserve enough evidence to review a report while honoring your stated deletion and retention policy. Do not let moderators browse every private conversation without a justified support or safety workflow.

Test the failure cases

Chat can look finished during a simple two-phone demo and still fail in normal use. Test the states that create duplicates, missing messages, and permission leaks.

TestExpected result
Send while connection dropsOne pending message becomes sent or clearly failed
Retry several timesOnly one database message exists
Receive same event twiceUI shows one bubble
Reopen after being offlineMissed messages load from the database
User removed from groupHistory and channel access follow the product policy immediately
Account B guesses room IDBackend and RLS deny access
Attachment URL is sharedPrivate file still requires valid access
Push opens deleted roomApp shows a safe unavailable state
Sender edits or deletesEvery active client reconciles with the server record
Session expiresConnection reauthenticates or closes without leaking events

Test release builds on real iOS and Android devices. Background behavior, push notifications, and reconnection can differ from a development preview. The mobile app testing checklist covers the wider release flow.

Real-time chat launch checklist

  • Database records remain the source of truth.
  • Every room and message query verifies membership.
  • Realtime channels are private and authorized.
  • Client IDs prevent duplicate sends.
  • History uses cursor pagination.
  • Reconnect fetches missed records.
  • Typing and presence remain temporary events.
  • Read positions update deliberately.
  • Attachments use private storage rules.
  • Push notifications respect mute and block settings.
  • Rate limits, reporting, and moderation are active.
  • Failure cases pass on real devices.

Build your chat app with Huxly

Huxly helps you build the mobile chat interface, Supabase backend, authentication, message database, private real-time channels, attachments, and notification flow in one project. You can test the complete conversation experience on real devices and prepare the Expo, Flutter, or SwiftUI app for TestFlight and Google Play.

FAQ

Should chat messages use Broadcast or Postgres Changes?

Both can work. Postgres Changes is simple for listening to inserted rows. Database-triggered Broadcast gives more control over room topics and can be a better fit as traffic grows. In both cases, store the message first.

Do I need WebSockets for mobile chat?

You need a live transport for immediate updates, and WebSockets are a common choice. You still need database history and a reconnect query because a mobile socket can disconnect or miss events.

How do I prevent duplicate messages?

Generate a client ID before sending, store it with a uniqueness rule, and reuse it for retries. Deduplicate incoming events by the saved message ID or client ID.

Should typing indicators be saved in the database?

No. Send them as throttled temporary events and clear them with both a stop event and a local timeout.

How should unread counts work?

Store each member's latest read position, then count newer eligible messages. Update the position when the conversation is visibly open, not when a notification arrives.

Can a public attachment URL be protected by hiding it?

No. Use a private bucket, enforce conversation membership, and return authenticated or short-lived access only after authorization.

Conclusion

A mobile chat feature stays reliable when durable storage and temporary events have separate jobs. Save and authorize the message first, then use the real-time channel to update active clients quickly.

Build reconnect, deduplication, unread state, notifications, and abuse controls into the first usable release. Those systems matter more than decorative chat features when real users start sending messages across unstable mobile connections.

Keep reading