Designing 1-1 and Group Chat: Storing Messages and Read Status
1. Overview
Chat looks simple: send a line of text and display it. But when you have millions of users, you face harder questions. How do you maintain message order? How do you prevent duplicates on retry? How do you track who has read what without storing billions of “seen” records?
This article walks you through a complete design for 1-1 and group chat. The design covers requirements, data model, APIs, workflows, and hard problems. By the end, you’ll be able to build the core message storage and read state system using Spring Boot 3, PostgreSQL, and Kafka.
2. Requirements
2.1. Functional
- 1-1 and group chat (up to 1,000 members per group).
- Send text messages. Messages appear in strict order within a conversation.
- View history and paginate backwards.
- Conversation list (inbox) with unread count.
- Read status: 1-1 chat shows “Seen”, group chat shows “Seen by N people”.
2.2. Scale and Constraints
- 10 million DAU. Average 40 messages per person per day, so 400 million messages/day, roughly 4,600 messages/sec. Peak hours may spike to 30,000 messages/sec.
- No message loss after successful acknowledgment.
- Message order only needs to be correct within a conversation. Global ordering not required.
- Clients may retry due to network issues. Server must be idempotent.
- Read events happen far more often than sends, since opening the app triggers them.
The third constraint is the key to the entire design. Since we only need local order, everything can be sharded by conversation_id.
3. Data Model
The central idea: each conversation has a strictly increasing sequence number (seq). The Nth message in a conversation has seq = N. A member’s read state is just a single number, last_read_seq. It’s a watermark, not a list of individually read messages.
CREATE TABLE conversation (
id BIGINT PRIMARY KEY,
type VARCHAR(10) NOT NULL, -- DIRECT | GROUP
last_seq BIGINT NOT NULL DEFAULT 0,
last_message_at TIMESTAMPTZ
);
CREATE TABLE conversation_member (
conversation_id BIGINT NOT NULL REFERENCES conversation(id),
user_id BIGINT NOT NULL,
last_read_seq BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (conversation_id, user_id)
);
CREATE INDEX idx_member_user ON conversation_member(user_id);
CREATE TABLE message (
conversation_id BIGINT NOT NULL,
seq BIGINT NOT NULL,
sender_id BIGINT NOT NULL,
client_msg_id UUID NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (conversation_id, seq),
UNIQUE (conversation_id, client_msg_id)
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
msg_key VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Key points:
- The primary key
(conversation_id, seq)lets you scan history as a contiguous range in the index. This is also your natural shard key when partitioning. UNIQUE (conversation_id, client_msg_id)blocks duplicates at the database layer.- Unread count is
last_seq - last_read_seq. NoCOUNT(*)needed. - “Seen by” in groups is the count of members with
last_read_seq >= seq. You don’t need amessage_receipttable with billions of rows. - For 1-1 chat, add a
direct_pair(user_low, user_high, conversation_id)table with a unique constraint. Two users opening chat simultaneously won’t create two conversations.
Note: The watermark assumes sequential reading—reading message N means you’ve read everything before it. This holds true for almost all chat apps. If you need to know exactly which specific message was opened (e.g., ephemeral messages), store that separately for just that message type. Don’t apply it to every message.
4. API
| Method | Endpoint | Description |
|---|---|---|
POST |
/conversations/{id}/messages |
Send a message. Body contains clientMsgId and body |
GET |
/conversations/{id}/messages?beforeSeq=&limit= |
History, paginated by seq |
POST |
/conversations/{id}/read |
Body { "seq": 120 } |
GET |
/inbox?limit= |
List conversations with unread counts |
WS |
/ws |
Server pushes message.new and read.updated events |
Messages go over REST instead of WebSocket. This gives you retry, timeout, and idempotency the way HTTP works. WebSocket is only for pushing to clients.
5. Send Flow
Dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
The entire send operation happens in one transaction:
public record SentMessage(long conversationId, long seq, UUID clientMsgId) {}
@Service
public class MessageService {
private final JdbcTemplate jdbc;
public MessageService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public SentMessage send(long conversationId, long senderId, UUID clientMsgId, String body) {
// 1. Lock the conversation row: all sends to the same conversation are serialized
Long lastSeq = jdbc.queryForObject(
"SELECT last_seq FROM conversation WHERE id = ? FOR UPDATE",
Long.class, conversationId);
// 2. Idempotency: if client retries, return the old result
List<Long> existing = jdbc.queryForList(
"SELECT seq FROM message WHERE conversation_id = ? AND client_msg_id = ?",
Long.class, conversationId, clientMsgId);
if (!existing.isEmpty()) {
return new SentMessage(conversationId, existing.getFirst(), clientMsgId);
}
// 3. Assign new seq, insert message and update conversation
long seq = lastSeq + 1;
jdbc.update("""
INSERT INTO message(conversation_id, seq, sender_id, client_msg_id, body)
VALUES (?, ?, ?, ?, ?)""", conversationId, seq, senderId, clientMsgId, body);
jdbc.update("UPDATE conversation SET last_seq = ?, last_message_at = now() WHERE id = ?",
seq, conversationId);
// 4. Sender has obviously read their own message
jdbc.update("""
UPDATE conversation_member SET last_read_seq = GREATEST(last_read_seq, ?)
WHERE conversation_id = ? AND user_id = ?""", seq, conversationId, senderId);
// 5. Outbox: event written in same transaction, never out of sync with data
jdbc.update("INSERT INTO outbox(msg_key, payload) VALUES (?, ?::jsonb)",
String.valueOf(conversationId),
"""
{"type":"message.new","conversationId":%d,"seq":%d,"senderId":%d}"""
.formatted(conversationId, seq, senderId));
return new SentMessage(conversationId, seq, clientMsgId);
}
}
Explanation of each step:
- Step 1 uses
SELECT ... FOR UPDATE. Two requests to the same conversation serialize, so seq never collides or jumps. Different conversations don’t block each other. - Step 2 checks for duplicates after acquiring the lock. So two concurrent retries of the same
clientMsgIdcan’t both slip through. If you checked before locking, both requests might see “not found”. The second one would then hitDuplicateKeyException. - Step 3 increments seq continuously with no gaps. This lets clients detect missing messages (see 7.3).
- Step 5 implements transactional outbox. If you send directly to Kafka inside the transaction, you risk Kafka accepting it while DB rolls back, or vice versa.
Calling send twice with the same clientMsgId:
send(1, 10, a1b2..., "Hello group") -> SentMessage[conversationId=1, seq=42, ...]
send(1, 10, a1b2..., "Hello group") -> SentMessage[conversationId=1, seq=42, ...] // no new message created
A separate relay reads the outbox table by id, pushes to topic chat.events with key conversationId, and deletes sent rows. You can also use Debezium. Keying by conversation ensures all events for the same conversation go to the same partition and stay ordered.
The consumer side pushes events to WebSocket gateways:
@Component
public class FanoutListener {
private final MemberRepository members; // get all userIds of a conversation
private final GatewayRegistry gateways; // userId -> active sessions
public FanoutListener(MemberRepository members, GatewayRegistry gateways) {
this.members = members;
this.gateways = gateways;
}
@KafkaListener(topics = "chat.events", groupId = "fanout")
public void onEvent(ConsumerRecord<String, String> record) {
long conversationId = Long.parseLong(record.key());
for (long userId : members.findUserIds(conversationId)) {
gateways.pushIfOnline(userId, record.value());
}
}
}
Offline users don’t get pushed, and that’s fine. When they reconnect, they call /inbox and pull messages with seq greater than their last one.
6. Read State
Marking as read is just one monotonic UPDATE:
@Service
public class ReadStateService {
private final JdbcTemplate jdbc;
public ReadStateService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public boolean markRead(long conversationId, long userId, long seq) {
int updated = jdbc.update("""
UPDATE conversation_member m
SET last_read_seq = LEAST(?, c.last_seq)
FROM conversation c
WHERE c.id = m.conversation_id
AND m.conversation_id = ? AND m.user_id = ?
AND m.last_read_seq < LEAST(?, c.last_seq)""",
seq, conversationId, userId, seq);
return updated == 1; // true if watermark actually moved forward
}
public long unreadCount(long conversationId, long userId) {
return jdbc.queryForObject("""
SELECT c.last_seq - m.last_read_seq
FROM conversation c JOIN conversation_member m ON m.conversation_id = c.id
WHERE c.id = ? AND m.user_id = ?""", Long.class, conversationId, userId);
}
}
This UPDATE solves three problems at once:
- Race conditions: phone sends
seq=50, laptop sendsseq=45later. The conditionlast_read_seq < ...prevents the older request from pulling the watermark back. No locking or versioning needed. - Garbage data:
LEAST(?, c.last_seq)stops clients from sending seq beyond actual messages. - Reducing load: when
updated == 0, the watermark didn’t change so there’s noread.updatedevent to send. Pointless fanout is cut.
Only when markRead returns true does the service write a read.updated event to outbox, similar to section 5.
7. Hard Problems and Solutions
7.1. “Hot” Conversations and Lock Contention
FOR UPDATE serializes sends in one conversation. For 1-1 or small group chat, this cost is negligible. A hot group of 1,000 members running at dozens of messages/sec to the same row is still fine. Each transaction holds the lock only a few milliseconds.
If a conversation exceeds hundreds of messages/sec (like a broadcast channel), move seq assignment to Redis with INCR chat:seq:{conversationId}. In exchange, you must accept seq gaps when DB write fails. Clients need to distinguish “real gap” from “message in flight”.
Warning: Don’t use
created_ator global IDs (Snowflake) to order messages in a conversation. Clock skew of a few milliseconds between servers is enough to reverse two messages sent close together.
7.2. Consistency Between DB, Kafka, and Client
The guarantees in this design:
- DB is the source of truth. Messages are acked to clients only after commit.
- Outbox guarantees at-least-once delivery to Kafka. Consumers may see duplicates.
- Clients dedup by
(conversationId, seq). Already-seen messages are dropped.
7.3. Detecting Missing Messages
Since seq is contiguous, clients can detect gaps themselves. Suppose a client has seq=40 and receives push for seq=43. It knows 41 and 42 are missing, so it calls GET /messages?afterSeq=40. You don’t need complex per-message ACKs from the server.
7.4. Handling Traffic Spikes
- Large group fanout: a message to a 1,000-person group creates 1,000 pushes. For groups above a threshold (e.g., 200 members), only push to online users via Redis set
online:{conversationId}. Payload is just a lightweight notification{conversationId, seq}; clients pull content when needed. - Read receipt storms: after a network outage, millions of clients reconnect and send read receipts together. Clients should debounce for 1–2 seconds and send only the highest seq. The server can then batch in Redis, keeping only the max per key
{conversationId}:{userId}, and flush to DB in batches. - Kafka: partition by
conversationIdto preserve order. As load grows, add consumers, up to the partition count. - Storage: 400 million messages/day, ~300 bytes each, roughly 120 GB/day. Partition the
messagetable byconversation_id(hash) and move old messages to cheaper storage like Cassandra or object storage. History queries always includeconversation_id, so any storage model works.
8. Testing Key Behaviors
The two most important behaviors to test: retrying doesn’t create duplicates, and the watermark never goes backward. Test with real PostgreSQL via Testcontainers since logic depends on FOR UPDATE and GREATEST/LEAST.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Place the DDL from section 3 in src/main/resources/schema.sql and enable spring.sql.init.mode=always.
@SpringBootTest(properties = {
"spring.sql.init.mode=always",
"spring.kafka.listener.auto-startup=false"
})
@Testcontainers
class ChatServiceTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired MessageService messages;
@Autowired ReadStateService reads;
@Autowired JdbcTemplate jdbc;
@BeforeEach
void setUp() {
jdbc.execute("TRUNCATE message, outbox, conversation_member, conversation");
jdbc.update("INSERT INTO conversation(id, type) VALUES (1, 'GROUP')");
jdbc.update("INSERT INTO conversation_member(conversation_id, user_id) VALUES (1, 10), (1, 20)");
}
@Test
void resendingSameClientMessageIdDoesNotCreateDuplicate() {
UUID clientMsgId = UUID.randomUUID();
SentMessage first = messages.send(1, 10, clientMsgId, "Hello group");
SentMessage retry = messages.send(1, 10, clientMsgId, "Hello group");
assertThat(retry.seq()).isEqualTo(first.seq()).isEqualTo(1L);
assertThat(jdbc.queryForObject("SELECT count(*) FROM message", Long.class)).isEqualTo(1L);
}
@Test
void readWatermarkNeverMovesBackward() {
for (int i = 0; i < 5; i++) {
messages.send(1, 10, UUID.randomUUID(), "message " + i);
}
assertThat(reads.markRead(1, 20, 4)).isTrue();
assertThat(reads.markRead(1, 20, 2)).isFalse(); // old request arrives late
assertThat(reads.markRead(1, 20, 99)).isTrue(); // capped to last_seq = 5
assertThat(reads.unreadCount(1, 20)).isZero();
assertThat(reads.unreadCount(1, 10)).isZero(); // sender auto-reads
}
}
The second test covers three scenarios. The watermark advances to 4. Request seq=2 arrives late and is dropped, returning false. A seq beyond actual messages is capped to 5, leaving unread count at 0. The property spring.kafka.listener.auto-startup=false prevents the test from trying to connect to Kafka.
9. Conclusion
The entire design pivots on one decision: each conversation has a continuous seq. From seq, you get message order, history pagination, gap detection, and unread count via a simple subtraction. Read state is a monotonic watermark, updated with one conditional UPDATE. This eliminates both race conditions and massive receipt tables.
Everything else is familiar engineering. clientMsgId plus a unique constraint give you idempotency. Transactional outbox with Kafka keyed by conversation ensure consistency and event order. Debouncing, pushing only to online users, and pulling on reconnect let the system survive traffic spikes. Start with PostgreSQL and FOR UPDATE. Only split seq assignment to Redis or change storage when real numbers tell you that you must.