DBMS 📂 Real Use cases · 4 of 4 42 min read

Chat Application Database Design with Redis Caching

Design the database for a chat app like WhatsApp in MySQL, then speed it up with Redis. Covers table schemas for users, conversations, messages, receipts and reactions, UML use-case and ER diagrams, why a database alone struggles at chat scale, and how Redis handles recent messages, presence, unread counts and real-time fan-out.

Section 01

What We Are Building

A Post Office That Never Sleeps
A chat app is like a post office where millions of tiny letters fly every second. Each letter must reach the right people, in the right order, and show a "delivered" and "read" tick. People also want to know who is online right now, who is typing, and how many unread messages they have.

Storing letters safely is easy for a normal database. The hard part is speed: a chat must feel instant. That is why we pair a durable database (MySQL) with a fast cache (Redis). This tutorial designs both sides.

We design the database for a chat app like WhatsApp or Slack. We cover users, conversations (direct and group), messages, attachments, read receipts, and reactions. Then we explain why a database alone struggles at chat speed, and how Redis caching fixes it. The engine is MySQL / MariaDB.

💬
Two Stores, Two Jobs

MySQL is the source of truth: it keeps every message forever, safely. Redis is the fast lane: it keeps the hot, recent, or throwaway data (recent messages, online status, unread counts) so the app answers in a few milliseconds.


Section 02

The Nine Tables at a Glance

GroupTableWhat It HoldsKey Link
IdentityusersPeople who chat—
IdentitydevicesPhones/browsers for push & presence→ users
IdentityblocksWho blocked whom→ users
RoomsconversationsA direct chat or a group—
Roomsconversation_membersWho is in a chat + read pointer→ conversations, users
MessagesmessagesEvery message ever sent→ conversations, users
Messagesmessage_attachmentsImages and files on a message→ messages
Messagesmessage_receiptsDelivered / read per person→ messages, users
MessagesreactionsEmoji reactions on a message→ messages, users

Section 03

Users, Devices, and Blocks

A user is one account. A device is one phone or browser, used for push notifications and presence. A block row hides two people from each other.

-- ============ USERS ============
CREATE TABLE users (
    id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    username      VARCHAR(40)     NOT NULL,
    display_name  VARCHAR(120)    NOT NULL,
    phone         VARCHAR(20)     NULL,
    avatar_url    VARCHAR(255)    NULL,
    last_seen_at  TIMESTAMP       NULL,      -- hot column; better kept in Redis (see caching)
    created_at    TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uq_username (username),
    UNIQUE KEY uq_phone (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ DEVICES (push tokens + presence) ============
CREATE TABLE devices (
    id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    user_id         BIGINT UNSIGNED NOT NULL,     -- FK -> users
    platform        ENUM('android','ios','web') NOT NULL,
    push_token      VARCHAR(255)    NULL,
    last_active_at  TIMESTAMP       NULL,
    PRIMARY KEY (id),
    KEY ix_dev_user (user_id),
    CONSTRAINT fk_dev_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ BLOCKS ============
CREATE TABLE blocks (
    blocker_id  BIGINT UNSIGNED NOT NULL,   -- FK -> users (who blocks)
    blocked_id  BIGINT UNSIGNED NOT NULL,   -- FK -> users (who is blocked)
    created_at  TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (blocker_id, blocked_id),
    CONSTRAINT fk_block_er FOREIGN KEY (blocker_id) REFERENCES users(id) ON DELETE CASCADE,
    CONSTRAINT fk_block_ed FOREIGN KEY (blocked_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
⚠️
last_seen_at Is a Trap in the Database

Presence changes every few seconds for every online user. Writing last_seen_at to the users table that often creates a write storm on hot rows. We keep the column for a fallback, but the live value belongs in Redis. Section 10 shows how.


Section 04

Conversations and Members

One table holds both chat types. A direct conversation has two members. A group has many, with a title and admins. The conversation_members table also stores last_read_message_id, which powers unread counts.

👤 Direct chat
type = direct
exactly 2 members
title is NULL
👥 Group chat
type = group
many members, some admins
title = group name
-- ============ CONVERSATIONS ============
CREATE TABLE conversations (
    id               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    type             ENUM('direct','group') NOT NULL,
    title            VARCHAR(150)    NULL,        -- group name; NULL for direct
    created_by       BIGINT UNSIGNED NULL,        -- FK -> users
    last_message_id  BIGINT UNSIGNED NULL,        -- pointer for chat-list order (no FK: avoids a cycle)
    created_at       TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY ix_conv_type (type),
    CONSTRAINT fk_conv_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ CONVERSATION MEMBERS ============
CREATE TABLE conversation_members (
    conversation_id       BIGINT UNSIGNED NOT NULL,   -- FK -> conversations
    user_id               BIGINT UNSIGNED NOT NULL,   -- FK -> users
    role                  ENUM('member','admin') NOT NULL DEFAULT 'member',
    last_read_message_id  BIGINT UNSIGNED NULL,       -- drives unread counts
    is_muted              TINYINT(1)      NOT NULL DEFAULT 0,
    joined_at             TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (conversation_id, user_id),
    KEY ix_member_user (user_id),
    CONSTRAINT fk_cm_conv FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
    CONSTRAINT fk_cm_user FOREIGN KEY (user_id)         REFERENCES users(id)         ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
🔑
last_read Pointer Beats a Row Per Message

To know unread count, store one last_read_message_id per member. Unread = messages after that id. This is far cheaper than one "read" row per person per message, which explodes in big groups.


Section 05

ER Diagram (1/2) — Identity and Rooms

Each arrow reads one-to-many (1 → N). blocks links to users twice (blocker and blocked).

📊 ER Diagram (1/2) — Users, Devices, Conversations
1→N blocker/blocked 1→N 1→N devices PK id FK user_id users PK id username conversations PK id type blocks FK blocker_id FK blocked_id conversation_members FK conversation_id FK user_id

Section 06

Messages, Attachments, Receipts, Reactions

The messages table is the heart of the app. It uses millisecond timestamps so messages sort in the exact order they were sent. Attachments, receipts, and reactions hang off each message.

-- ============ MESSAGES ============
CREATE TABLE messages (
    id               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    conversation_id  BIGINT UNSIGNED NOT NULL,   -- FK -> conversations
    sender_id        BIGINT UNSIGNED NOT NULL,   -- FK -> users
    type             ENUM('text','image','file','system') NOT NULL DEFAULT 'text',
    body             TEXT            NULL,
    reply_to_id      BIGINT UNSIGNED NULL,       -- FK -> messages (a reply)
    created_at       TIMESTAMP(3)    NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    edited_at        TIMESTAMP(3)    NULL,
    deleted_at       TIMESTAMP(3)    NULL,      -- soft delete
    PRIMARY KEY (id),
    KEY ix_msg_timeline (conversation_id, created_at),  -- the "load chat" query
    KEY ix_msg_sender (sender_id),
    CONSTRAINT fk_msg_conv   FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
    CONSTRAINT fk_msg_sender FOREIGN KEY (sender_id)       REFERENCES users(id),
    CONSTRAINT fk_msg_reply  FOREIGN KEY (reply_to_id)     REFERENCES messages(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ MESSAGE ATTACHMENTS ============
CREATE TABLE message_attachments (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    message_id  BIGINT UNSIGNED NOT NULL,   -- FK -> messages
    url         VARCHAR(255)    NOT NULL,
    media_type  VARCHAR(50)     NOT NULL,   -- image/jpeg, application/pdf
    size_bytes  BIGINT UNSIGNED NULL,
    PRIMARY KEY (id),
    KEY ix_att_msg (message_id),
    CONSTRAINT fk_att_msg FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ MESSAGE RECEIPTS (delivered / read) ============
CREATE TABLE message_receipts (
    message_id  BIGINT UNSIGNED NOT NULL,   -- FK -> messages
    user_id     BIGINT UNSIGNED NOT NULL,   -- FK -> users
    status      ENUM('delivered','read') NOT NULL,
    at          TIMESTAMP(3)    NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    PRIMARY KEY (message_id, user_id),
    KEY ix_rcpt_user (user_id),
    CONSTRAINT fk_rcpt_msg  FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
    CONSTRAINT fk_rcpt_user FOREIGN KEY (user_id)    REFERENCES users(id)    ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ REACTIONS ============
CREATE TABLE reactions (
    message_id  BIGINT UNSIGNED NOT NULL,   -- FK -> messages
    user_id     BIGINT UNSIGNED NOT NULL,   -- FK -> users
    emoji       VARCHAR(16)     NOT NULL,
    created_at  TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (message_id, user_id, emoji),
    CONSTRAINT fk_react_msg  FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
    CONSTRAINT fk_react_user FOREIGN KEY (user_id)    REFERENCES users(id)    ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
📈
Receipts Grow Fast in Big Groups

In a 500-person group, one message can create 500 receipt rows. Multiply by millions of messages and this table becomes the biggest one you have. Many apps skip per-message read rows for groups and use the last_read pointer plus Redis counters instead.


Section 07

ER Diagram (2/2) — Messaging

Messages sit at the centre. Dashed boxes are tables defined earlier. A message can also reply to another message (self link).

📊 ER Diagram (2/2) — Messages & Extras
1→N 1→N 1→N 1→N 1→N conversations defined earlier users defined earlier messages PK id FK conversation_id FK sender_id body / created_at message_attachments FK message_id url / media_type message_receipts FK message_id status reactions FK message_id emoji

Section 08

UML Use-Case Diagram

Two actors. A normal user chats; a group admin also manages the group.

👤 UML Use-Case — User and Group Admin
Chat Application User Group Admin Send message Read & react See presence Send attachment Add / remove members Rename group

Section 09

Why a Database Alone Struggles

The schema above is correct and durable. But a busy chat app asks the database for the same hot data thousands of times a second. A disk-based database was not built for that. Here are the pain points.

🔥
Hot recent messages
read storm
Everyone opens the same active chats and reads the last 50 messages again and again. That is the same query, millions of times.
👤
Presence & typing
write storm
Online status and "typing…" change every few seconds. Writing that to disk beats up hot rows and creates lock waits.
🔔
Unread counts
slow aggregate
Counting unread messages for every chat on every screen open is an expensive COUNT that runs far too often.
⏳
The Real Enemy Is Latency at Scale

A chat must feel instant, under ~100 ms. A single database can handle a lot, but disk reads, row locks, and connection limits add up fast when the same hot data is asked for constantly. The fix is not a bigger database. It is a cache in front of it.


Section 10

Redis to the Rescue — Caching Architecture

The app checks Redis first. On a hit it answers in memory. On a miss it reads MySQL, then fills the cache so the next read is fast.

⚡ Architecture — App + Redis + MySQL
① check cache (fast) ② on miss, read DB ③ warm cache Clients web iOS / Android WebSocket App servers chat API WebSocket hub cache-aside logic Redis (cache + real-time) ● recent messages (List) ● presence + typing (TTL) ● unread counters (INCR) ● pub/sub fan-out MySQL (source of truth) ● full message history ● users & conversations ● durable & searchable ● survives a crash
💡
Cache-Aside in One Line

"Look in Redis. If it is there, use it. If not, read MySQL, put it in Redis, then use it." This is the cache-aside (lazy loading) pattern, and it is the workhorse of chat apps.


Section 11

What to Cache, and How

NeedRedis structureWhyLives in DB too?
Recent messagesLIST / ZSETRead the last 50 without touching diskYes
Online / last seenSTRING + TTLRefreshed by a heartbeat; expires on its ownFallback
Unread countINCR counterAdd one on new message, reset on readRebuildable
Typing indicatorSTRING + short TTLThrowaway; never needs savingNo
Real-time deliveryPUB/SUBPush a new message to all servers at onceNo
Login sessionSTRING + TTLFast auth check on every requestOptional

Redis commands for the common cases

# Cache the newest messages of conversation 42 (keep only the last 100)
LPUSH chat:42:messages "{id:9001,from:57,body:'hi'}"
LTRIM chat:42:messages 0 99
LRANGE chat:42:messages 0 49          # read last 50, no DB hit

# Presence: mark user online for 30 seconds; heartbeat refreshes it
SETEX presence:user:57 30 "online"
GET presence:user:57                    # NULL means offline

# Unread counter per user per conversation
INCR unread:user:57:conv:42            # new message arrives
SET  unread:user:57:conv:42 0          # user opens the chat

# Typing indicator: shows for 5 seconds, then vanishes on its own
SETEX typing:conv:42:user:57 5 "1"

# Real-time fan-out: tell every app server a new message landed
PUBLISH conv:42 "{id:9001,from:57}"

Section 12

Caching Patterns Compared

🔄
Cache-aside
App reads cache; on a miss it reads the DB and fills the cache. Simple and safe. The default choice for reading recent messages.
read-heavy · lazy
💾
Write-through
Every write goes to the DB and the cache together. Cache is always fresh, but each write is a little slower. Good for data read right after it is written.
fresh · slower writes
⏳
Write-behind
Write to the cache now, save to the DB a moment later in the background. Very fast, but risky: a crash can lose the not-yet-saved writes. Use with care.
fast · risk of loss
✅ Cache hit
App asks Redis
Data is there
Answer in ~1 ms
Database untouched
❌ Cache miss
App asks Redis, nothing there
Read MySQL (slower)
Write result into Redis
Next read is a hit

Section 13

Sending a Message — The Full Flow

01
Save to MySQL (source of truth)
Insert the row into messages. This is the durable copy that survives forever.
02
Update Redis
Push the message to the conversation's cached list, and bump each other member's unread counter.
03
Fan out with pub/sub
PUBLISH the message so every app server pushes it to online members over WebSocket.
04
Notify the offline
For members who are offline (no presence key), send a push notification to their devices.
-- 1) durable write in MySQL
INSERT INTO messages (conversation_id, sender_id, type, body)
VALUES (42, 57, 'text', 'Running 5 minutes late!');
SET @msg_id = LAST_INSERT_ID();

-- keep the chat list ordered by newest
UPDATE conversations SET last_message_id = @msg_id WHERE id = 42;
# 2) update the cache, then 3) fan out (run right after the DB write)
LPUSH chat:42:messages "{id:9001,from:57,body:'Running 5 minutes late!'}"
LTRIM chat:42:messages 0 99
INCR  unread:user:88:conv:42          # the other member
PUBLISH conv:42 "{id:9001,from:57}"
OUTPUT — what each member sees
online member -> message appears instantly over WebSocket offline member -> push notification, unread badge = 1 sender -> single tick (sent), then double tick on delivery

Section 14

Consistency — Keep Cache and Database in Step

A cache is a copy. Copies go stale. The rule is simple: MySQL is always right; Redis is a fast copy you can rebuild. Design so a wrong or missing cache value is never a disaster.

🧰 Keeping the Cache Honest
Edit
When a message is edited or deleted, update or drop its cached copy: DEL chat:42:messages and let it rebuild.
TTL
Give cached lists a time limit so a stale value fixes itself: EXPIRE chat:42:messages 3600.
Rebuild
On a miss, always be able to rebuild from MySQL. Never store data only in Redis that you cannot lose.
Order
Write to MySQL first, then the cache. If the cache write fails, the truth is still safe on disk.
🛡️
Redis Can Persist, But Treat It as a Cache

Redis can save to disk (RDB or AOF), and that is worth turning on. But for a chat app, plan as if Redis could vanish at any moment. If losing Redis only means a slower first read while it warms up again, your design is right.


Section 15

Golden Rules

💬 Chat App Data — Non-Negotiable Rules
1
MySQL is the source of truth. Every message is saved to disk first. Redis is a fast copy you can always rebuild from it.
2
Index the timeline query. (conversation_id, created_at) on messages powers "load this chat". It is your most-run query.
3
Cache the hot, recent data. The last messages, presence, and unread counts belong in Redis. Do not ask the disk for them thousands of times a second.
4
Keep throwaway data out of the DB. Typing indicators and live presence use Redis with a TTL. They never need to be saved.
5
Use a last_read pointer for unread. One pointer per member beats a read row per person per message, which explodes in big groups.
6
Write DB first, then cache. If the cache update fails, the truth is still safe. Invalidate or refresh the cache on edits and deletes.
7
Fan out with pub/sub. Use Redis PUB/SUB to push new messages to all app servers, then to online users over WebSocket. Push notifications cover the offline.
You have completed Real Use cases. View all sections →