# Design a Chat System: Store First, Then Push (2026)

> A chat system looks like sending a message and is really three problems: keeping millions of connections open, never losing or duplicating a message, and catching up devices that were offline. The prompt, the numbers, and the design that answers all three.

*Source: https://www.infowok.com/design-a-chat-system/ · Sukhveer Kaur · Published October 24, 2026*

---

Two phones sit on a table. One types "On my way", presses send, and the other buzzes before the thumb has left the screen. It feels like nothing happened in between.

Now the interviewer says: "Design that." Behind it, millions of phones hold a connection open at once. A message must survive a dropped signal in a lift, never show twice on a retry, and reach a friend who switched their phone off for the weekend, in order, on Monday.

The whole design comes down to one habit: **store the message first, then push it.** This lesson shows why, and what the rest of the system needs to make that safe.

<Prerequisites>

- [The system design interview framework](/system-design-interview-framework-2026/) and [back-of-envelope estimation](/back-of-envelope-estimation-primer/).
- [Message delivery guarantees](/message-delivery-guarantees-explained/), because a chat send is that lesson with a human on both ends, and [the news feed lesson](/design-a-news-feed/) for sending one item to many readers.

</Prerequisites>

<Callout type="note" title="Remember from the delivery guarantees lesson">
Why is "at-least-once delivery plus an idempotent receiver" the usual answer instead of "exactly once"? Because networks fail between the work and the acknowledgement, so retries are unavoidable, and a receiver that can recognise a repeat makes retries harmless.
</Callout>

<KeyTakeaways>

- Store a message durably before pushing it, so a missed push loses nothing.
- Make sending idempotent: the client attaches its own id and a retry never creates a second message.
- Let the server number messages per conversation, because client clocks cannot order them.
- A device keeps a cursor, the last sequence number it saw, and resumes from it after any gap.
- Messages are durable and typing is ephemeral. Keep them in separate lanes.

</KeyTakeaways>

<LessonOutcome>

Design a chat system end to end in 45 minutes: choose how connections are held, make sends safe to retry, keep messages in order, catch up offline devices, and handle groups, receipts and typing.

</LessonOutcome>

## Step 1: Pin Down the Problem

Ask first, then state your assumptions out loud: one-to-one or groups too, how big a group gets, one device or several, how long history lives, what "delivered" means, and whether messages are end-to-end encrypted. Here: both, up to 100,000 members (assumed), several devices, kept forever (assumed), "delivered" means a device of the receiver has it, and encryption is a follow-up.

**Functional requirements:**

1. Send a message in a one-to-one chat or a group.
2. Receive messages in real time while online.
3. See history, and catch up on everything missed while offline.
4. See whether a message was sent, delivered and read.
5. Attach photos and short videos. The upload design is in the companion lesson; here a message carries only a reference.

**Out of scope** (say these out loud):

- Voice and video calls, search and reactions. Each is its own problem. End-to-end encryption is a follow-up in the companion lesson.
- Push notifications as a service, the next problem. Here a notification only wakes a device.

**Non-functional requirements**, each with a number. These are assumptions you announce:

- Fast: a few hundred milliseconds to an online friend.
- Durable: an acknowledged message is never lost.
- Ordered: every member of a conversation sees messages in the same order.
- Available: sending still works if one server dies.
- Consistent where it counts: per conversation, order beats availability. In CAP terms the log leans consistent, so if a conversation's owner is unreachable, its sends wait for a standby rather than risk two orders. In PACELC terms (the trade-off with no split), the ack waits for the durable write, paying latency for order. Presence and typing lean available and may be stale.
- Scale: assume 10 million daily users. Step 4 turns that into numbers.

## Step 2: Entities and API

```text
User          id
Conversation  id, members
Message       conv_id, seq,
              sender, text,
              client_msg_id

Over the connection:
send {conv, client_msg_id, text}
  -> ack {client_msg_id, seq}
  -> or 403 not a member,
     413 too large, 429 slow down
message {conv, seq, sender, text}
GET /sync?since=<cursor>&limit=50
  -> messages, next_cursor, more
POST /uploads -> presigned URL
```

Every call carries a per-device token, and the server checks membership on each send. Pages use the cursor, never offsets, because new messages shift offsets. Two fields do the heavy lifting. `client_msg_id` is made by the sender, so a retry can be recognised. `seq` is made by the server, so every member agrees on the order.

<Callout type="note" title="How a real system does it">
Matrix's send call is `PUT /rooms/{roomId}/send/{eventType}/{txnId}`. The client-made transaction id lets the server [recognise a retransmission and return the same response](https://github.com/matrix-org/matrix-spec/blob/main/content/client-server-api/_index.md), including the original event id. Reading uses `/sync`, whose `next_batch` token the client passes as `since` next time.
</Callout>

## Step 3: A Design That Works

Start with the simplest thing. Every online client asks the server every two seconds: "any new messages since the last one I have?"

![First working design, polling: every online client asks the server for new messages every two seconds, so 2 million online clients make a million requests a second and almost all find nothing new](./design-a-chat-system-v1.svg)

This is polling. It is correct, and where many candidates stop.

<Callout type="tip" title="Predict first">
Assume 2 million clients are online at the same time and each asks every two seconds. How many requests a second is that, and what does the typical answer say?
</Callout>

Call it **1,000,000 requests a second** (2,000,000 clients × 0.5), almost all answered "nothing new", and a message can wait two seconds. The fix is to let the server speak first.

## Step 4: Estimate What Matters

```text
Assume 10M daily users
40 messages sent each a day

Messages: 400M / 86,400
  ≈ 4,600/s
Peak (assume 5x): 23,000/s
Online at peak: 20% = 2M
Per gateway: 100,000 connections
  -> 20 gateways, plus spares
```

Then storage and upkeep:

```text
Storage: 400M x 200 B
  = 80 GB a day (≈ 29 TB a year)
Heartbeats: 2M / 30 s
  (assume a ping every 30 s)
  ≈ 67,000 pings/s
```

Each line decides something:

- **20 gateways** means connections, not messages, size the front of the system.
- Assume **100,000 connections per gateway**; it depends on hardware and software, so measure yours. Exactly 20 leaves no room: if one dies, its devices must fit on the other 19. Run a few spares.
- **67,000 pings a second** is the quiet cost of keeping connections alive. Zulip's queue servers [send a heartbeat at least every 45 seconds or so](https://github.com/zulip/zulip/blob/main/docs/subsystems/events-system.md) because some NAT servers kill connections idle for over 60 seconds.
- **29 TB a year** is the reason messages need a store built for appends, and a plan for old data (text only: media would dwarf it).
- **≈ 4.6 MB a second** of message text at peak (23,000 × 200 B) means bandwidth is not the constraint. Copying to group members and media are what multiply it.

![Assumed load numbers for the chat system: 10 million daily users, about 4,600 messages a second and 23,000 at peak, 2 million open connections needing about 20 gateways, 80 GB of new messages a day and about 29 TB a year](./design-a-chat-system-scale.svg)

## Step 5: Deep Dives

Six questions decide this design. Follow the interviewer if they jump ahead.

### How Does a Message Reach an Online Friend?

<Concept href="/event-driven-architecture/" title="Event-driven architecture">
A chat is events flowing to subscribers. The question here is the last hop, down to one phone.
</Concept>

<DesignOption tier="works" title="Polling">
As in step 3. No special server, and it works behind any proxy.

<Tradeoff>About 1,000,000 requests a second at the assumed scale, mostly empty, and a message can wait a full interval.</Tradeoff>
</DesignOption>

<DesignOption tier="scales" title="Long polling or one open connection">
The server answers the moment something arrives. In long polling, that is an HTTP request held open. A WebSocket is a connection both sides can use at any time.

Real systems choose differently. Zulip's documentation says long polling [works everywhere, unlike WebSockets, which have a decreasing but nonzero level of client compatibility problems](https://github.com/zulip/zulip/blob/main/docs/subsystems/events-system.md). Mastodon's [streaming server](https://github.com/mastodon/mastodon/blob/main/streaming/index.js) accepts WebSockets and HTTP event streams.

<Tradeoff>Servers hold millions of long-lived connections, each needing a heartbeat so a silent one is noticed.</Tradeoff>
</DesignOption>

<DesignOption tier="survives" title="A gateway layer plus a route table">
Put the connections in their own layer of gateways. A chat service does the thinking and a route table says which gateway holds each device. A load balancer or small discovery service picks a gateway with spare room, and that gateway writes the device's route with an expiry its heartbeat keeps refreshing. If the gateway dies, its routes vanish on their own and the next connection overwrites them. To deliver, the service looks up the receiver's gateways and hands them the message to push down the open connections.

<Tradeoff>An extra hop and a table to keep right, plus a burst of reconnects when a gateway dies.</Tradeoff>
</DesignOption>

#### Is There a Version Without a Route Table?

Yes. Let a Pub/Sub system do the routing. Gateways subscribe to channels for what they hold, and the chat service publishes instead of looking up routes. Since Redis 7.0 [sharded Pub/Sub](https://github.com/redis/docs/blob/main/content/develop/pubsub/_index.md) keeps a message inside the shard that owns the channel instead of sending it to every node. It is at-most-once, so it can only be the fast path. The real decision is what a channel is named after:

| | Channel per user | Channel per chat |
|---|---|---|
| Gateway subscribes to | each user it holds | each chat its users are in |
| Publishes per message | one per member | one |
| Join or leave a chat | nothing to change | resubscribe |
| Fits | one-to-one and small groups | big groups |

A 500-member group costs 500 publishes per user channel but one per chat channel, at the price of every gateway tracking each chat its users are in. A common hybrid: per user by default, per chat once a group passes a size you pick and measure.

![Delivery to an online user: the sender holds a connection to gateway A, the chat service stores the message and looks up which gateway holds the receiver, and gateway B pushes it down the open connection](./design-a-chat-system-gateways.svg)

#### What Happens When a Gateway Dies?

Assume a gateway holding 100,000 connections fails and every device reconnects and reads from its cursor.

```text
100,000 devices reconnect
all at once: 100,000 in 1 s
spread over 30 s:
  100,000 / 30 ≈ 3,300/s
peak messages: 23,000/s
```

All at once is about four times the assumed peak message rate (100,000 ÷ 23,000 ≈ 4.3). A random wait before each retry turns it into ≈ 3,300 reads a second.

#### Why Store Before You Push?

The push can miss, for example when the receiver's connection drops at the wrong moment. Redis documents Pub/Sub as [at-most-once, so a message missed during a disconnect is lost for good](https://github.com/redis/docs/blob/main/content/develop/pubsub/_index.md). If your push path is Pub/Sub, the durable log underneath is what saves you.

### How Do You Send Safely and Keep Order?

<Concept href="/message-delivery-guarantees-explained/" title="Message delivery guarantees">
The sender cannot tell "message lost" from "ack lost", so it must be able to retry, and the receiver must recognise a retry.
</Concept>

<DesignOption tier="works" title="Order by client timestamp">
Sort by the time on the sender's device.

<Tradeoff>Phone clocks disagree, so a reply can appear before its question, and ties have no order.</Tradeoff>
</DesignOption>

<DesignOption tier="scales" title="Order by server timestamp">
The server stamps each message when it arrives.

<Tradeoff>Better, but two servers can still stamp out of order, and ties remain.</Tradeoff>
</DesignOption>

<DesignOption tier="survives" title="A sequence number per conversation, made by one writer">
Route every send for a conversation to the one server that owns it, chosen by sharding on the conversation id. That owner hands out 1, 2, 3 in the order it saves messages. Redis Streams show the idea: an entry id is `<ms>-<seq>` and an explicit id [must be greater than every id already in the stream](https://github.com/redis/docs/blob/main/content/commands/xadd.md).

Pair it with an idempotent send. The client puts its own id on the message. The server keeps a record of ids it has seen.

```text
client_msg_id = abc
seen abc?
  no:  seq = 58, save, ack(58)
  yes: ack(58) again, no save
```

A Redis Stream log can do the dedupe for you. From Redis 8.6, [`XADD` accepts `IDMP` with a producer id and an idempotent id](https://github.com/redis/docs/blob/main/content/develop/data-types/streams/idempotency.md), and a repeat of the same pair returns the original entry instead of adding a second one.

The memory is short: by default Redis keeps [100 ids per producer for 100 seconds](https://github.com/redis/docs/blob/main/content/develop/data-types/streams/idempotency.md), tunable with `XCFGSET`. Size it to how long clients keep retrying, and check your version first.

<Tradeoff>One owner per conversation is a single point of order: it needs a standby, and a very busy conversation can go only as fast as its owner.</Tradeoff>
</DesignOption>

#### Where Does the Log Live?

Key the log by conversation id and sort it by sequence number. One conversation's messages then sit together and read back in order, and the conversation id is also the shard key that picks the owner. Any store with a partition key and a sort key fits; the access pattern matters more than the product.

Watch hot partitions. Discord's engineering post [partitions messages by channel plus a bucket, a static time window](https://discord.com/blog/how-discord-stores-trillions-of-messages), describes busy channels becoming "hot partitions" that slowed whole clusters, and a data service in front queries the database once when many users ask for the same row. Bucketing a huge channel by time is our reading of the idea.

![Sending safely: the client sends a message with its own id, the server checks whether it has seen that id, if not it numbers and saves the message, then acknowledges; a retry gets the same acknowledgement](./design-a-chat-system-send.svg)

<Callout type="key" title="A rule to remember">
**The server numbers, the client retries, the cursor resumes.** The server decides the order. The client may retry as often as it likes. The cursor is how a device picks up again after any gap.
</Callout>

### What About Devices That Were Offline?

<Concept href="/caching-strategies/" title="Caching strategies">
The log is the source of truth; a device that missed a push rebuilds from it.
</Concept>

<DesignOption tier="works" title="A pending queue per user">
Keep a queue of undelivered messages for each user. Push each new message into the queue of every member who is offline, and flush the queue in order when they reconnect.

<Tradeoff>A group of 500 means 500 copies, people who never return need cleanup, and a second device needs its own queue.</Tradeoff>
</DesignOption>

<DesignOption tier="scales" title="A cursor per device">
The device keeps the last sequence number it saw. On reconnect it asks for everything after that, in pages. Matrix does this with `/sync` and a `since` token. When too many events arrived, it returns a "limited" timeline with a gap, and the client [pages back to fill it](https://github.com/matrix-org/matrix-spec/blob/main/content/client-server-api/_index.md).

<Tradeoff>Each device has its own cursor. If you trim old history, a cursor older than the oldest kept message cannot page forward; tell it to reset and reload, as in the next option.</Tradeoff>
</DesignOption>

<DesignOption tier="survives" title="Resumable, with a safe reset">
Let the server forget cursor state it cannot afford to keep. Zulip's clients send `last_event_id` as an acknowledgement, so the server can delete older events. Idle queues are [garbage-collected after 10 minutes by default](https://github.com/zulip/zulip/blob/main/docs/subsystems/events-system.md), and a returning client that gets "queue not found" simply reloads its state. 

A push notification only wakes a device. It tells the phone to reconnect and resume, and it carries no guarantee that the message arrives.

<Tradeoff>A reset forces a full reload, so it must be cheap.</Tradeoff>
</DesignOption>

![Catching up after being offline: the device remembers seq 57, reconnects asking for what comes after, the server returns the next page, the device saves its new cursor, and a full page means ask again](./design-a-chat-system-catchup.svg)

#### How Does an Online Device Notice a Missed Push?

Numbers in a conversation go up by one, so a gap is visible. The device does not need to know why 58 went missing. It treats the gap like a short offline spell and reads from its cursor.

![Spotting a missed push: the device holds cursor 57, a push arrives with seq 59, so 58 is missing; it asks the log for everything after 57, shows 58 and 59 and moves its cursor to 59](./design-a-chat-system-gap.svg)

Two backstops catch what a gap cannot show:

- **The lost last message.** If the final message of a quiet chat goes missing, no later number reveals it. So a device syncs from its cursor on every reconnect, and now and then while open.
- **The silent dead connection.** A socket can die unnoticed. The gateway closes a connection that misses a few heartbeats, so the device reconnects and resumes.

### What About a User With Several Devices?

Matrix treats [each desktop client, browser and phone as its own device with a `device_id`](https://github.com/matrix-org/matrix-spec/blob/main/content/_index.md), which shapes three decisions:

- **Connections, routes and cursors are per device.** One message may need pushes to several gateways, and a phone off all weekend and a laptop open all day hold different cursors.
- **The sender's other devices get the message too.** Zulip sends an event to [every client the sender controls as well as the recipients](https://github.com/zulip/zulip/blob/main/docs/subsystems/events-system.md), so the sending device can run the same code as every other client.
- **The read marker belongs to the user, not the device.** Matrix allows only [one receipt per user and receipt type](https://github.com/matrix-org/matrix-spec/blob/main/content/client-server-api/modules/receipts.md) (per thread), so reading on the laptop clears the unread count on the phone.

<Callout type="note" title="A rule for delivered">
"Delivered" needs a definition. "Any device of the receiver got it" is a common, simple choice. It is a judgement, so say it out loud.
</Callout>

### How Do Groups, Receipts and Typing Fit?

A group is the news feed problem again: copy each message into every member's inbox, or store it once and let members read it. For chat, **store it once, in the conversation log**. Members hold a cursor into that one log, which is why a group of 500 costs one write, not 500.

A receipt per message is a lot of traffic, so Matrix [uses "read up to here" markers](https://github.com/matrix-org/matrix-spec/blob/main/content/client-server-api/modules/receipts.md): one marker means every earlier message is read.

A common way to show status is three states. This is a design choice, not a standard:

- **Sent:** the server saved it and returned the ack with its `seq`.
- **Delivered:** a device of the receiver got it.
- **Read:** the receiver's read marker passed its `seq`.

Typing and presence take a different lane:

- **Typing** is ephemeral. Matrix says typing notifications are not part of the room's event history, that the server [keeps no list of users who are not typing](https://github.com/matrix-org/matrix-spec/blob/main/content/client-server-api/modules/typing_notifications.md), and that clients keep re-sending while the user types, every ≈ 20 to 30 seconds at most.
- **Presence** (who is online) is a judgement call. Broadcasting every change is expensive, so limit it to people looking at that person, and fetch it when a big group opens instead of pushing. For "last seen", write the time when a connection closes, not on every heartbeat.

  The building block is a heartbeat that keeps refreshing a key that expires on its own. Mastodon's [streaming server](https://github.com/mastodon/mastodon/blob/main/streaming/index.js) does this for subscribed channels, every 6 minutes with an expiry of three times that. It tracks subscriptions, not who is online, but if the pings stop, the key expires.

![Two lanes: durable messages and read-up-to markers are stored and resumed from a cursor, while ephemeral typing and presence are sent live, repeated while true and forgotten](./design-a-chat-system-groups.svg)

## The Final Design in One Picture

![Final design: clients connect to gateways, the chat service dedupes, numbers and stores each message in a per-conversation log and looks up each member's devices in a route table, gateways push live messages, an offline device resumes from its cursor, a notification service nudges devices with no connection, and media sits in a blob store behind a CDN](./design-a-chat-system-final.svg)

Follow one message:

1. Device A sends it with a `client_msg_id` to a gateway.
2. The chat service checks the id, assigns the next sequence number, saves it to the log and acknowledges.
3. It looks up each member's devices in the route table and hands the message to their gateways to push.
4. A member with no connection gets a notification that wakes the device, which later asks for everything after its cursor.

Photos and videos travel beside this path, with only a reference in the message.

## Where the Production Detail Lives

The core answer ends here. What interviewers probe once it stands up is in the companion lesson, [Design a Chat System: Media, Failure Modes and Follow-Ups](/design-a-chat-system-follow-ups/):

| If you are asked | The companion covers |
|---|---|
| "What happens when X dies?" | A failure table, component by component, with why each is safe |
| "What about encryption, abuse, regions, cost?" | Short answers to eight follow-ups, plus a 45-minute plan |
| "How do photos and videos work?" | Three upload designs, from the chat path to resumable uploads and cached delivery |

## What Each Level Should Show

| Level | Expected in the room |
|---|---|
| Mid-level | WebSocket or long polling, a store for messages, a rough scale estimate. Needs a hint about retries and ordering. |
| Senior | Gateways and a route table, an idempotent send, per-conversation sequence numbers, a cursor for offline catch-up. |
| Staff+ | Store-then-push reasoning, reconnect storms, safe resets, ephemeral versus durable lanes, owner takeover, failure and consistency trade-offs stated, and what to build first. |

## Red Flags and Green Flags

<AnswerCompare>
<Weak label="Red flag">
"Use WebSockets and Redis Pub/Sub to forward messages to the receiver." Nothing about retries, order, or a friend who is offline.
</Weak>
<Strong label="Green flag">
"I'll store each message in a per-conversation log first, then push it as a fast path. The client sends its own id so retries are safe, the server numbers messages, and each device resumes from a cursor, so a missed push loses nothing."
</Strong>
</AnswerCompare>

<Callout type="warning" title="The most common way to lose this round">
Spending the time on WebSocket versus long polling and never reaching "what if the message is sent twice, or the receiver is offline?". Transport is one deep dive; durability and order are the rest.
</Callout>

## Try It: A Channel With 100,000 Members

<Exercise title="One huge group" time="15 min">

Change one variable. A channel has **100,000 members**. Assume 20% are online and the channel sees 10 messages a second.

1. How many live pushes does one message cause?
2. How many a second at that rate?
3. How do you change the design?

<Hint>

Multiply the members by the share online, then by the message rate. Then ask what you must store per member.

</Hint>

<Expected title="One reasonable answer">

```text
100,000 x 20% = 20,000 pushes
x 10 msgs/s = 200,000/s
```

One message causes 20,000 live pushes, and the channel makes 200,000 a second. The gateways can take it only if each message is stored once. Do not copy the message into 100,000 inboxes. Keep one log, give each member a cursor, publish once to a channel per chat, and push a small nudge that members fetch from. Drop typing and presence for channels this big.

</Expected>

</Exercise>

<InInterview>

Expect: "What if the same message is sent twice?", "How do you keep order?", "What if the receiver is offline?", then the group of 100,000. Give each answer a trade-off and a number.

</InInterview>

## Check Yourself

<CheckYourself>

<Question q="Why store a message before pushing it?">

The push can miss. If the message is already in a durable log, the device picks it up from its cursor later.

</Question>

<Question q="A phone sends a message, the connection drops before the ack arrives, and the phone retries. How do you avoid a duplicate?">

The client attached its own id to the message. The server has already seen that id, so it returns the original acknowledgement instead of saving a second message.

</Question>

<Question q="Why not order messages by the time on the sender's phone?">

Clocks disagree, so messages can reorder or tie. The server assigns an increasing sequence number per conversation, through one writer.

</Question>

<Question q="An online phone holds seq 57 and the next push is seq 59. What does it do?">

58 is missing, because numbers go up by one. It asks the log for everything after 57, as after being offline, then moves its cursor.

</Question>

</CheckYourself>

<Callout type="tip" title="Learning tip">
Tomorrow, draw the final design from memory and trace a message through one failure at a time: receiver offline, ack lost, gateway dead.
</Callout>

## In One Picture

```text
Requirements
  send, read, catch up, receipts
Scale
  ≈ 23,000 msgs/s peak, 2M conns
Connections
  gateways + route table
Send
  client id, server seq number
Offline
  log + cursor per device
Lanes
  durable messages, live typing
Rule
  store first, then push
```

## Conclusion

**The 60-second answer:** a chat system is a durable log with a fast path in front. Devices hold connections to gateways; the chat service dedupes by client id, numbers each conversation through one owner, stores the message, acks, then pushes. Offline or gapped devices resume from a per-device cursor. Typing and presence stay out of the log, and media goes to blob storage with a reference.

**Which failure would you walk through first in the room?** Tell us in the comments.

<NextSteps>

- **The delivery guarantees behind a send:** [message delivery guarantees](/message-delivery-guarantees-explained/).
- **One item to many readers:** [design a news feed](/design-a-news-feed/).
- **The production side of this answer:** [media, failure modes and follow-ups](/design-a-chat-system-follow-ups/).
- **Next in Module 4:** [design a notification service](/design-a-notification-service/), where the device is asleep and a message must still reach it once.

</NextSteps>
