Designing a Chat Application

By Oleksandr Andrushchenko — Published on
0 Likes
0 Dislikes
Designing a Chat Application
Designing a Chat Application

A chat application looks simple from the client perspective: a user sends a message and another user receives it. At scale, that interaction requires persistent connections, message routing, durable storage, ordering, offline delivery, synchronization across multiple devices, and infrastructure capable of maintaining millions of concurrent connections.

The central design challenge is separating two different workloads: real-time message delivery and durable message storage. Connected users expect messages within milliseconds, while disconnected users still expect complete conversation history when they return. A production architecture must support both without making one depend unnecessarily on the other.

Table of Contents

Requirements and Scale Estimation

A chat system can contain dozens of features, so the interview scope should be narrowed early. A useful design supports:

  • one-to-one conversations;
  • group conversations;
  • real-time message delivery;
  • persistent message history;
  • offline users;
  • multiple devices per user;
  • delivery and read status.

Large attachments, voice calls, end-to-end encryption, search, and massive public channels can be treated as separate extensions unless explicitly required.

Important non-functional requirements include low delivery latency, high availability, durable messages, horizontal scalability, and predictable ordering within a conversation.

Assume:


Daily active users:           50 million
Concurrent users:             10 million
Messages/user/day:            40
Messages/day:                  2 billion
Average stored message:       1 KB
Peak multiplier:              4×

Average message creation throughput is:


2,000,000,000 / 86,400
≈ 23,000 messages/second

Peak throughput becomes approximately:


23,000 × 4
≈ 92,000 messages/second

Raw message storage grows by roughly:


2 billion × 1 KB
≈ 2 TB/day

≈ 730 TB/year

Replication, indexes, metadata, backups, and storage-engine overhead increase the physical requirement significantly.

The other important number is not requests per second but concurrent connections:


10 million online users
≈ 10 million persistent connections

If one connection server safely handles 50,000 active connections:


10,000,000 / 50,000
= 200 connection servers

With operational headroom, deployments, and failure capacity, the actual fleet would be larger.

This illustrates why chat systems require both throughput estimation and connection-capacity estimation. The broader methodology is covered in Estimating Scale and Capacity Planning.

API and Data Model

A chat system usually combines HTTP APIs with a persistent real-time protocol.

HTTP can handle operations such as loading conversation history:


GET /conversations/{conversation_id}/messages?before={message_id}&limit=50

Message sending and real-time delivery can use WebSockets:


{
  "type": "message.send",
  "conversation_id": "conv_781",
  "client_message_id": "client_9182",
  "body": "Hello"
}

The server can acknowledge the accepted message:


{
  "type": "message.accepted",
  "client_message_id": "client_9182",
  "message_id": "msg_481927",
  "sequence": 1842
}

The client-generated identifier is useful for idempotency. If a connection fails before the acknowledgement arrives, the client can safely retry the same logical message.

A simplified data model might include:


Conversation

conversation_id
type
created_at


ConversationMember

conversation_id
user_id
joined_at
last_read_sequence


Message

conversation_id
sequence
message_id
sender_id
body
created_at

The primary access pattern is usually:


conversation_id
      +
message sequence/time range
      |
      v
conversation history

That suggests partitioning and indexing messages around the conversation rather than around the sender.

For example, conceptually:

SELECT *
FROM messages
WHERE conversation_id = ?
  AND sequence < ?
ORDER BY sequence DESC
LIMIT 50;

Cursor-based pagination is preferable to large offsets because old conversations can contain millions of messages.

Persistent Connections and WebSockets

Polling is inefficient for real-time chat. A client repeatedly asking whether anything changed generates traffic even when no messages exist:


Client --> Any messages? --> Server
Client --> Any messages? --> Server
Client --> Any messages? --> Server
Client --> Any messages? --> Server

A WebSocket establishes a long-lived bidirectional connection:


Client <======================> Connection Server
           persistent
           WebSocket

The server can immediately push new messages when they become available.

At large scale, clients connect to many connection servers:


                       Load Balancer
                    /       |        \
                   v        v         v
              Gateway 1 Gateway 2 Gateway 3
               /  |       /  |       /  |
              v   v      v   v      v   v
            users       users       users

The load balancer distributes new connections, but once established, each WebSocket remains associated with a particular gateway until it disconnects.

This creates a routing problem. Suppose:


Alice --> Gateway 2
Bob   --> Gateway 9

When Alice sends Bob a message, the system must discover that Bob is currently connected to Gateway 9.

A distributed connection registry can maintain mappings such as:


user_123
  device_phone  --> gateway_9 / connection_829
  device_web    --> gateway_4 / connection_192

user_456
  device_phone  --> gateway_2 / connection_716

Connection state is ephemeral. It changes whenever clients connect, disconnect, switch networks, or reconnect to another server.

The registry therefore needs fast reads and writes but does not need to be treated like permanent conversation history.

Designing the Message Flow

A simple one-to-one message flow can be represented as:


Alice
  |
  v
Gateway A
  |
  v
Message Service
  |
  +------> Message Store
  |
  +------> Message Bus
                |
                v
            Router
                |
                v
           Gateway B
                |
                v
               Bob

The important design decision is where a message becomes durable.

A safe flow is:


1. Receive message
2. Validate membership
3. Assign message ID / sequence
4. Persist message durably
5. Publish delivery event
6. Acknowledge accepted message
7. Route to connected recipients

A message should not be considered safely accepted merely because one application server has it in memory.

The message bus separates durable ingestion from real-time fan-out:


                 Message Service
                       |
                       v
                  Message Bus
                 /     |      \
                v      v       v
            Router  Search   Analytics

Real-time delivery, indexing, analytics, notifications, and other downstream consumers can process the same logical event independently.

This avoids adding every secondary feature to the synchronous message-send path.

One-to-One Fan-Out

For direct messages, the router resolves the recipient's active connections:

def route_message(message):
    connections = connection_registry.get(
        message.recipient_id
    )

    for connection in connections:
        gateway_bus.publish(
            gateway_id=connection.gateway_id,
            event=message,
        )

If no active connection exists, the message remains safely stored and can be synchronized when the recipient reconnects.

Group Chat Fan-Out

Group conversations introduce another scaling dimension.

For a small group:


                   Message
                      |
            +---------+---------+
            |         |         |
            v         v         v
         User B    User C    User D

Fan-out on write is reasonable because the recipient count is small.

For a group with hundreds of thousands of members, immediately generating individual delivery work for every member can create enormous write amplification.


1 message
   |
   v
500,000 members
   |
   v
500,000 delivery operations

Large groups may require a different model: store the message once in a conversation log and let clients consume from their position rather than materializing a separate copy for every user.

The right strategy therefore depends on group size and delivery requirements.

Message Ordering and Delivery Guarantees

Global ordering across the entire chat platform is unnecessary and extremely expensive. What users normally care about is ordering within a conversation.

Suppose two messages arrive concurrently:


Alice: "A"
Bob:   "B"

Different servers may observe them in different orders unless the system establishes a canonical conversation sequence.

One approach assigns monotonically increasing sequence numbers per conversation:


Conversation 781

1840  Hello
1841  How are things?
1842  Good
1843  Great

Clients can then sort and synchronize messages according to this sequence.

The partitioning strategy can help preserve ordering:


partition = hash(conversation_id)

Messages for the same conversation are routed through the same logical partition:


Conversation A ----\
Conversation A -----+--> Partition 3
Conversation A ----/

Conversation B ----\
Conversation B -----+--> Partition 7

This provides an ordering boundary without imposing global serialization on unrelated conversations.

At-Least-Once Processing

Distributed message pipelines commonly use at-least-once delivery.

Consider:


Message Service
      |
      v
Message Bus
      |
      v
Router
      |
      v
Gateway
      |
      v
Client receives message
      |
Gateway crashes before ACK

The event may be processed again.

Clients and services should therefore tolerate duplicate delivery using the stable message_id:

def receive_message(message):
    if local_store.contains(message.id):
        return

    local_store.save(message)
    render(message)

The broader difference between delivery guarantees is covered in At-Most-Once vs At-Least-Once vs Exactly-Once Message Delivery.

Client Retries

Client retries create a similar duplicate problem.


Client sends M1
      |
      v
Server stores M1
      |
      X acknowledgment lost

Client retries M1

Without an idempotency identifier, the server may create two messages.

The client should generate a stable identifier:


client_message_id = 550e8400-e29b-41d4-a716-446655440000

The server stores the relationship:


sender_id + client_message_id
              |
              v
          message_id

Repeated submissions can then return the existing message rather than creating another one.

Offline Users and Multi-Device Synchronization

Real-time delivery is only an optimization over durable message history. If a user is offline, no real-time connection exists:


Alice sends message
       |
       v
Message stored durably
       |
       v
Bob offline
       |
       X no WebSocket delivery

When Bob reconnects, the client synchronizes from its last known position:


Client state:

conversation_781
last_sequence = 1837

Request:

messages after sequence 1837

The server returns:


1838
1839
1840
1841
...

This mechanism is more reliable than trying to keep undelivered messages only in an ephemeral gateway queue.

Multiple Devices

A user may be connected from a phone, laptop, and browser simultaneously:


                    User
               /      |      \
              v       v       v
           Phone    Laptop   Browser
             |         |        |
             v         v        v
         Gateway A Gateway C Gateway F

The connection registry should therefore map a user to multiple active connections, not one server.

Incoming messages can be routed to all active devices. Read state then needs synchronization as well.

Suppose a message is read on the phone:


Phone
  |
  | read through sequence 1842
  v
Read State Service
  |
  +------> Laptop
  |
  +------> Browser
  |
  +------> sender receipt

Storing the latest read sequence is more efficient than storing a separate boolean read record for every message in many designs:


user_123
conversation_781
last_read_sequence = 1842

Every message with a sequence less than or equal to 1842 is implicitly read.

Push Notifications for Offline Users

Offline recipients may need mobile push notifications:


New chat message
      |
      v
Recipient offline?
      |
     yes
      |
      v
Notification System
      |
      v
APNs / FCM

Push delivery should remain outside the core message persistence path. A temporary push-provider outage should not prevent messages from being stored.

The notification pipeline is covered separately in Designing a Notification System.

Production Design Example

A production-oriented chat architecture can combine WebSocket gateways, stateless message services, durable storage, partitioned messaging, connection discovery, and asynchronous downstream processing.


                           Clients
                              |
                              v
                      +----------------+
                      | Load Balancer  |
                      +----------------+
                              |
                  +-----------+-----------+
                  |           |           |
                  v           v           v
              Gateway A   Gateway B   Gateway C
                  |           |           |
                  +-----------+-----------+
                              |
                              v
                    +-------------------+
                    | Message Service   |
                    +-------------------+
                       /             \
                      v               v
              Message Store       Message Bus
                                      |
                               partitioned by
                              conversation_id
                                      |
                                      v
                              Message Router
                               /     |      \
                              v      v       v
                        Gateway A Gateway B Gateway C
                              |
                              v
                           Clients

Connection Registry <------ Gateways

Message Bus
    |
    +------> Notification Service
    |
    +------> Search Indexer
    |
    +------> Analytics

Suppose Alice sends a message to Bob.

Step 1: Alice sends through the existing WebSocket.


Alice --> Gateway A

Step 2: Gateway A forwards the command to the Message Service.

The Message Service validates that Alice belongs to the conversation and checks the client idempotency key.

Step 3: The service assigns the canonical message identity and sequence.


message_id:       msg_900821
conversation_id:  conv_781
sequence:         1842

Step 4: The message becomes durable.


Message Store

partition:
hash(conv_781)

record:
conv_781 / 1842 / msg_900821

Step 5: A delivery event enters the message bus.


partition key = conversation_id

This keeps events from the same conversation on the same logical ordering path.

Step 6: The router resolves Bob's connections.


Connection Registry

user_bob:
  phone --> Gateway B
  web   --> Gateway C

Step 7: The event is delivered to both gateways.


               Message Router
                 /        \
                v          v
          Gateway B    Gateway C
              |            |
              v            v
          Bob phone     Bob web

If Gateway C disappears during delivery, Bob can still recover the message from durable history after reconnecting.

This illustrates a useful principle:


WebSocket delivery
     =
fast path

Message Store
     =
source of durable truth

Real-time delivery improves latency. It should not be the only mechanism guaranteeing that messages eventually appear.

The system should monitor both infrastructure and user-visible behavior:

Metric Why It Matters
Active WebSocket connections Measures gateway capacity and connection distribution
Connections per gateway Detects imbalance and overloaded instances
Message ingestion rate Measures write workload
End-to-end delivery latency Measures user-visible real-time performance
Message bus lag Detects routing backlog
Duplicate processing rate Reveals retries and unstable consumers
Reconnect rate Can reveal gateway or network instability
Offline synchronization latency Measures recovery performance after reconnect
Hot conversation partitions Identifies uneven message distribution

Percentile latency matters more than averages. A 30 ms average can hide a subset of users waiting several seconds because one gateway, partition, or region is overloaded.

Common Mistakes

Mistake Why It Causes Problems Better Approach
Designing chat as ordinary HTTP request/response Real-time clients need efficient server-to-client delivery. Use persistent connections such as WebSockets for active sessions.
Ignoring concurrent connections Gateway capacity is determined by connections as well as message throughput. Estimate active connections separately from RPS.
Keeping messages only in gateway memory Gateway failures can permanently lose messages. Persist messages independently of connection infrastructure.
Trying to guarantee global ordering Unrelated conversations become unnecessarily serialized. Define ordering within a conversation or another meaningful scope.
Ignoring client retries Lost acknowledgements can create duplicate messages. Use client-generated idempotency identifiers.
Mapping one user to one connection Modern users frequently have several active devices. Track all active connections for each user.
Treating WebSocket delivery as durable delivery Disconnected clients and failed gateways can miss events. Use durable history and synchronization as the recovery mechanism.
Fan-out on write for every group size Very large groups create extreme write amplification. Select fan-out strategy according to group size and workload.
Using offset pagination for long histories Large offsets become increasingly expensive. Use cursor or sequence-based pagination.
Putting notifications in the critical path Push-provider failures can interfere with message persistence. Trigger offline notifications asynchronously.

Interview Checklist

  • Define chat scope: clarify direct messages, groups, history, receipts, and multi-device requirements.
  • Estimate concurrent users: persistent connections can dominate gateway capacity.
  • Estimate message throughput: calculate average and peak message creation rates.
  • Estimate storage: message history can grow into hundreds of terabytes or petabytes.
  • Use persistent connections: support low-latency bidirectional communication for active clients.
  • Separate connection state from durable state: gateways are ephemeral; message history is not.
  • Maintain a connection registry: route messages to the gateways currently serving recipients.
  • Persist before relying on real-time delivery: gateway failure should not lose accepted messages.
  • Define ordering scope: conversation-level ordering is usually more practical than global ordering.
  • Use idempotency: handle client retries and duplicate asynchronous processing.
  • Support offline synchronization: recover from a durable conversation position after reconnect.
  • Support multiple devices: maintain several active connections and synchronize read state.
  • Choose group fan-out carefully: avoid enormous write amplification for very large groups.
  • Keep secondary processing asynchronous: notifications, analytics, and search should not block message persistence.
  • Monitor user-visible latency: track delivery percentiles, bus lag, reconnects, and synchronization delay.

Conclusion

A scalable chat system combines ephemeral real-time infrastructure with durable conversation storage. WebSocket gateways maintain millions of active connections, while message services, partitioned storage, and asynchronous routing ensure that accepted messages survive gateway failures and disconnected recipients.

The most important design boundaries are conversation-level ordering rather than global ordering, durable storage rather than gateway memory, and synchronization rather than assuming every real-time delivery succeeds. Multi-device users, large groups, retries, and offline clients then become extensions of the same model.

Key Takeaway

Real-time delivery should be fast but disposable; message history should be durable and recoverable. Use persistent connections for active users, store accepted messages independently of connection servers, partition ordering by conversation, make retries idempotent, maintain a distributed connection registry, and allow every client to recover missed messages from a durable synchronization position.

Comments (0)