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. No COUNT(*) needed.
  • “Seen by” in groups is the count of members with last_read_seq >= seq. You don’t need a message_receipt table 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 clientMsgId can’t both slip through. If you checked before locking, both requests might see “not found”. The second one would then hit DuplicateKeyException.
  • 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 sends seq=45 later. The condition last_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 no read.updated event 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_at or 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:

  1. DB is the source of truth. Messages are acked to clients only after commit.
  2. Outbox guarantees at-least-once delivery to Kafka. Consumers may see duplicates.
  3. 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 conversationId to 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 message table by conversation_id (hash) and move old messages to cheaper storage like Cassandra or object storage. History queries always include conversation_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.

system-designspring-bootpostgresqlkafkacase-study