← Back to list

Building a Chat Application From Scratch: High-Level System Design

Building a chat application looks deceptively simple.

Jazim Abbas · 2026-06-22 09:26 · 0 claps · 9.0 min read
#system-design-concepts #websocket #redis #chat-app-development #load-balancing
Open on Medium ↗
Wiki topics: 🔒 · Cybersecurity

High Level System Design Chat App

High Level System Design Chat App

Building a Chat Application From Scratch: High-Level System Design

Building a chat application looks deceptively simple.

At first glance, it seems like all we need is a way for users to send messages to each other. In reality, modern chat applications must solve a surprisingly large number of problems: real-time messaging, online presence, read receipts, typing indicators, message persistence, multi-device synchronization, and horizontal scaling.

There are plenty of libraries and managed services that make these problems easier to solve. Tools such as Socket.IO, AWS API Gateway WebSocket, and various real-time platforms can abstract away much of the complexity.

For production systems, that’s often the right decision.

However, the goal of this project is not to build a chat application as quickly as possible. The goal is to understand how chat systems actually work under the hood.

Instead of relying heavily on frameworks and managed services, we’ll use raw WebSockets, Redis, PostgreSQL, AWS infrastructure, and custom application logic. This approach forces us to solve the same challenges that large-scale messaging systems face every day.

In this article, we’ll focus on the high-level architecture. Future articles will cover database design, implementation details, scaling strategies, and the many edge cases that appear when building real-time systems.

Functional Requirements

Before designing the architecture, let’s define the features our chat application must support.

Core Features

  • One-to-one messaging
  • Group messaging
  • Online presence
  • Read receipts
  • Typing indicators
  • Message history

In addition to text messages, users should be able to exchange attachments such as PDFs, images, videos, and other file types.

High-Level Architecture

Rather than jumping directly into implementation details, let’s first understand how each feature works from a system design perspective.

We’ll start with the simplest possible architecture and gradually introduce the components needed to support scaling.

High Level Architecture

High Level Architecture

One-to-One Messaging

To understand the fundamentals, let’s begin with a single WebSocket server.

In a real-world environment we would eventually run multiple servers behind a load balancer, but starting with a single server makes it easier to understand the core concepts.

Assume two users are connected to our WebSocket server:

  • User A
  • User B

When a user establishes a WebSocket connection, the server stores a mapping between the user and their active connections.

For example:

{
  "user1": ["conn1"]
}

Notice that we store an array of connections rather than a single connection.

A user may be connected from multiple devices at the same time, such as:

  • Mobile
  • Web
  • Desktop

After User B connects, the server state might look like this:

{
  "user1": ["conn1"],
  "user2": ["conn2"]
}

A sends → server receives → looks up B’s connection → forwards

A sends → server receives → looks up B’s connection → forwards

Now suppose User A sends a message to User B.

The server simply checks its in-memory connection registry, finds User B’s active connection, and forwards the message through that WebSocket connection.

The flow is straightforward:

  1. User A sends a message.
  2. The server receives the message.
  3. The server looks up User B’s connection.
  4. The server forwards the message to User B.

This works perfectly well for a small application.

The challenge appears when the number of concurrent users grows beyond what a single server can handle.

Scaling Beyond a Single Server

At some point, a single WebSocket server becomes a bottleneck.

To handle more users, we need multiple WebSocket servers running behind a load balancer.

The architecture now looks something like this:

WS1 can’t see User B — cross-server delivery fails without a shared registry

WS1 can’t see User B — cross-server delivery fails without a shared registry

A user’s connection may be routed to any available WebSocket server.

For example:

  • User A connects to WS1
  • User B connects to WS2

Each server maintains its own in-memory connection registry.

This creates a new problem.

WS1 knows about User A, but it has no knowledge of User B’s connection because User B is connected to WS2.

So how can WS1 deliver a message to User B?

Introducing a Connection Registry

One option is to store connection ownership information in a database.

However, messaging systems perform these lookups extremely frequently. Querying a database for every message would quickly become a performance bottleneck.

Instead, we use Redis.

Redis provides an extremely fast in-memory data store that is ideal for frequently accessed connection data.

Whenever a user connects, we store the server ownership information in Redis.

For example:

{
  "user1": ["WS1"]
}

Because users may connect from multiple devices, each user can be associated with multiple WebSocket servers.

Meanwhile, each WebSocket server stores the actual connection objects locally:

{
  "user1": ["websocket_connection"]
}

This gives us two separate layers:

Redis

User → WebSocket Server

Server Memory

User → WebSocket Connection

Redis knows which server; each server knows which connection

Redis knows which server; each server knows which connection

Now any server can determine which WebSocket server owns a user’s active connection.

But we still need a way for servers to communicate with each other.

That brings us to Redis Pub/Sub.

Redis Pub/Sub for Message Routing

A naïve solution would be to connect every WebSocket server directly to every other WebSocket server.

While this may work with a small number of servers, it becomes increasingly difficult to manage as the system grows.

Instead, we can use Redis Pub/Sub.

Each WebSocket server subscribes to a dedicated Redis channel.

For example:

WS1 → channel: server-1
WS2 → channel: server-2
WS3 → channel: server-3

No direct server-to-server connections needed

No direct server-to-server connections needed

Suppose User A sends a message to User B.

The flow looks like this:

  1. WS1 receives the message.
  2. WS1 checks Redis to determine where User B is connected.
  3. Redis returns WS2.
  4. WS1 publishes the message to WS2’s channel.
  5. WS2 receives the event.
  6. WS2 looks up User B’s active WebSocket connections.
  7. WS2 forwards the message.

This approach allows WebSocket servers to communicate without maintaining direct connections to one another.

It also scales much more effectively as the number of servers grows.

Group Messaging

At first glance, group messaging looks very similar to one-to-one messaging.

A user sends a message, and the system delivers it to multiple recipients instead of just one.

However, group messaging introduces a new scaling challenge.

Consider a group with four members:

  • User 1
  • User 2
  • User 3
  • User 4

Assume each user is connected to a different WebSocket server.

When User 1 sends a message, WS1 needs to determine where every group member is currently connected.

A straightforward approach would be:

  1. Fetch all group members.
  2. Look up each member in Redis.
  3. Determine which server owns each user.
  4. Publish messages to the appropriate servers.

For a group of four users, this is trivial.

But what happens when a group contains hundreds of members?

Suddenly, every message requires hundreds of Redis lookups before delivery can begin.

Group chat delivery flow

Group chat delivery flow

For small groups, this approach is perfectly acceptable.

For larger groups, we may introduce caching to reduce lookup overhead.

For example, Redis could temporarily store a mapping like:

group:123
├── WS1
├── WS2
├── WS3
└── WS4

This allows the sender’s server to identify which servers are interested in messages from that group without performing individual lookups for every member.

Of course, caching introduces its own challenges.

What happens when:

  • A user disconnects?
  • A user reconnects to a different server?
  • A server crashes?

The cache must eventually reflect those changes.

For that reason, many systems use a short TTL (Time To Live) and periodically rebuild the cache rather than attempting to keep it perfectly synchronized.

We’ll explore those trade-offs in much greater detail later in this series.

Supporting Multiple Devices

Modern chat applications rarely assume a user has only one active device.

A user may be connected from:

  • Mobile
  • Web browser
  • Desktop application
  • Tablet

This means a single user can have multiple active WebSocket connections at the same time.

For example:

{
  "user1": ["mobile_conn", "web_conn"]
}

Suppose User 1 sends a message from their phone.

Most chat applications will immediately show that message on the user’s laptop as well.

To achieve this, the server must forward the message not only to the recipient but also to the sender’s other active devices.

Multi Device sync

Multi Device sync

The only connection we should exclude is the connection that originally sent the message. Otherwise, the sender would receive a duplicate copy on the same device.

This pattern is used extensively by applications such as WhatsApp, Slack, Discord, and Microsoft Teams.

Typing Indicators

Typing indicators are one of the simplest real-time features to implement.

The architecture is almost identical to normal message delivery.

The difference is that typing events are temporary.

They don’t need to be stored in the database.

A typical flow looks like this:

  1. User starts typing.
  2. Frontend sends a “typing started” event.
  3. Backend forwards the event to relevant participants.
  4. UI displays “User is typing…”
  5. User stops typing.
  6. Frontend sends a “typing stopped” event.

Typing Indicator Flow

Typing Indicator Flow

Because typing indicators are purely a user experience feature, they can be treated as transient events that disappear after a few seconds.

No persistence is required.

Online Presence

Online presence is another feature that appears simple but requires careful consideration.

The most common implementation uses heartbeats.

Every active client periodically sends a signal to the server indicating that it is still connected.

For example:

Every 30 seconds

The server updates the user’s last activity timestamp.

A simplified record might look like:

{
  "user1": {
    "lastActivityAt": "2026-01-01T05:00:00Z"
  }
}

Online presence / Heartbeat Flow

Online presence / Heartbeat Flow

When a user disconnects or loses network connectivity, heartbeats stop arriving.

If the system hasn’t received a heartbeat within a predefined window, the user is considered offline.

For example:

Current Time - Last Activity > 90 seconds

The application can then display:

  • Online
  • Offline
  • Last seen recently
  • Last seen 2 hours ago

We’ll discuss storage strategies for presence data in a future article.

Read Receipts

Read receipts provide users with visibility into message status.

A common implementation uses three states:

Sent

The server has successfully received and persisted the message.

Delivered

The message has reached the recipient’s device.

Read

The recipient has opened the conversation and viewed the message.

The flow typically looks like this:

Sent
  ↓
Delivered
  ↓
Read

Read Receipt State TransitionFlow

Read Receipt State TransitionFlow

For one-to-one conversations, tracking these states is relatively straightforward.

Group chats are more complicated.

Imagine a group containing 500 members.

Tracking delivery and read status for every user would require storing a large amount of additional data.

Many messaging platforms either simplify read receipts for large groups or introduce special optimizations to manage storage requirements.

We’ll examine those trade-offs when we design the database schema.

Sending Attachments

So far we’ve focused on text messages.

Attachments follow a very similar flow but introduce one additional component: object storage.

When a user uploads a file, we generally do not send the file itself through the WebSocket connection.

Instead, the process typically works like this:

  1. User uploads the file via HTTP.
  2. Backend stores the file in object storage.
  3. A public or signed URL is generated.
  4. The URL is included in the chat message.
  5. Message delivery proceeds normally.

The message payload might look something like:

{
  "type": "attachment",
  "fileUrl": "https://cdn.example.com/file.pdf"
}

Attachment Upload Flow

Attachment Upload Flow

Using HTTP uploads offers several advantages:

  • Better support for large files
  • Easier retry mechanisms
  • Reduced pressure on WebSocket servers
  • Better integration with CDN caching

The actual message delivery process remains unchanged.

Only the payload differs.

What We Haven’t Covered Yet

This article focused entirely on high-level architecture.

Many important topics are still ahead of us, including:

  • Database schema design
  • Message storage strategies
  • Conversation models
  • Group membership management
  • Read receipt persistence
  • Redis data structures
  • Authentication and authorization
  • Message ordering
  • Reconnection handling
  • Failure scenarios
  • Scaling considerations

Each of these topics deserves its own discussion because they introduce challenges that are not immediately obvious when building a chat application.

Final Thoughts

Building a chat application is much more than opening a WebSocket connection and exchanging messages.

Even a relatively small messaging platform must solve problems related to routing, persistence, presence tracking, synchronization, delivery guarantees, and scalability.

In this article, we explored a high-level architecture using:

  • WebSockets for real-time communication
  • Redis as a connection registry
  • Redis Pub/Sub for server-to-server communication
  • Load balancers for horizontal scaling
  • Object storage for attachments

In the next article, we’ll move one layer deeper and design the database schema that powers these features.

That’s where many of the interesting trade-offs begin to appear.

If you have any questions please do let me know in the comment section. If you want to support me, *buy me a coffee *here. Thanks for your time and support. Happy Coding.


메타데이터
post_id
5b2e0a77db20
slug
building-a-chat-application-from-scratch-high-level-system-design-5b2e0a77db20
url
https://medium.com/@jazimabbas/building-a-chat-application-from-scratch-high-level-system-design-5b2e0a77db20
canonical_url
https://medium.com/@jazimabbas/building-a-chat-application-from-scratch-high-level-system-design-5b2e0a77db20
author_url
https://medium.com/@jazimabbas
status
ok
fetched_at
2026-06-26 03:39:16