Frontend System Design: Chat Application (Messenger / Slack)

interviewMarch 28, 2026

The chat application question is one of the most demanding frontend system design problems — it covers real-time communication, offline-first architecture, complex state management, message delivery tracking, and multi-tab synchronisation all at once. It's frequently asked at Meta, Slack, OpenAI, and Airbnb.

This article walks through a full RADIO-framework answer. If you're unfamiliar with RADIO, start here.

R — Requirements

Functional requirements:

  • List of conversations in a sidebar
  • Select a conversation to view message history
  • Send and receive text messages in real-time
  • Messages show delivery status: Sending → Sent → Delivered → Read
  • Works offline — users can browse existing messages without network
  • Outgoing messages typed offline are queued and sent when back online
  • Draft messages (unsent) persist across sessions

Non-functional requirements:

  • Messages feel instant — optimistic UI, no waiting for server confirmation
  • Offline usage works gracefully (no crashes, clear status indicators)
  • Cross-tab consistency — opening two tabs should show the same messages
  • Works on desktop and mobile

Out of scope:

  • Group conversations (1:1 only for now)
  • Image/file attachments
  • Push notifications
  • End-to-end encryption

A — Architecture

Chat app architecture — UI layer, IndexedDB data layer, Server with REST and WebSocket

Chat apps have a fundamentally different architecture from typical web apps. The key insight: the UI reads from a local client-side database, not directly from the server. This is what enables offline support and cross-tab consistency.

┌──────────────────────────────────────────────────────────┐
│                      Chat UI                             │
│  ┌──────────────────┐  ┌───────────────────────────────┐ │
│  │ ConversationList  │  │     ConversationView          │ │
│  │  (sidebar)        │  │  ┌─────────────────────────┐ │ │
│  │  - avatar         │  │  │    MessageList          │ │ │
│  │  - last message   │  │  │    (virtualised)        │ │ │
│  │  - timestamp      │  │  └─────────────────────────┘ │ │
│  └──────────────────┘  │  ┌─────────────────────────┐ │ │
│                         │  │    MessageComposer      │ │ │
│                         │  └─────────────────────────┘ │ │
│                         └───────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
         │ reads from                     │ reads from
         ▼                                ▼
┌─────────────────────────────────────────────────────────┐
│              Controller / Data Layer                     │
│  ┌─────────────────────┐  ┌──────────────────────────┐  │
│  │    IndexedDB         │  │   Message Scheduler      │  │
│  │  (client database)   │  │   (outgoing queue)       │  │
│  └─────────────────────┘  └──────────────────────────┘  │
│  ┌─────────────────────────────────────────────────────┐ │
│  │          Data Syncer                                │ │
│  │   (server sync + WebSocket listener)                │ │
│  └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
         │ syncs with
         ▼
┌─────────────────────────┐
│         Server           │
│  REST API + WebSocket    │
└─────────────────────────┘

Why UI → IndexedDB (not UI → server)?

  1. Offline reads — IndexedDB works with no network
  2. Cross-tab consistency — all tabs access the same IndexedDB
  3. Optimistic writes — insert to local DB instantly, sync to server in background
  4. Decoupled architecture — UI doesn't care where data came from

Components:

  • ConversationList — sidebar, sorted by most recent message
  • ConversationView — selected conversation's messages + composer
  • MessageList — virtualised list of message bubbles
  • MessageComposer — input box, sends messages, persists drafts
  • MessageStatusIcon — shows sending/sent/delivered/read state

Data layer modules:

  • IndexedDB — the single source of truth for all local data
  • Data Syncer — connects to WebSocket, hydrates DB on load, handles incoming events
  • Message Scheduler — monitors pending outgoing messages, sends them in order, handles retries

D — Data Model

IndexedDB Tables

// Conversations between two users
type Conversation = {
  id: string;
  participantIds: string[];
  lastMessageId: string;
  lastMessageAt: string;   // ISO — used for sorting sidebar
  updatedAt: string;
};

// Individual messages
type Message = {
  id: string;             // Server-assigned (or temp client ID before sync)
  conversationId: string;
  senderId: string;
  content: string;
  status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
  createdAt: string;
  clientId: string;       // Stable ID generated client-side for deduplication
};

// User identities
type User = {
  id: string;
  displayName: string;
  avatarUrl: string;
};

// Unsent draft text per conversation
type DraftMessage = {
  conversationId: string;
  content: string;
  savedAt: string;
  // NOT synced to server — stays on this device
};

// Tracks outgoing messages pending server acknowledgement
type SendMessageRequest = {
  clientId: string;        // Links back to Message.clientId
  conversationId: string;
  content: string;
  status: 'pending' | 'in_flight' | 'failed' | 'success';
  failCount: number;
  lastAttemptAt: string | null;
  createdAt: string;
};

Client-Only State (not persisted)

type UIState = {
  selectedConversationId: string | null;
  scrollPositions: Record<string, number>; // conversationId → scroll offset
  composerDraft: Record<string, string>;   // In-memory; flush to DB on blur/debounce
};

Why Two Draft Stores?

  • composerDraft (in-memory): Updates every keystroke — too expensive to write to DB on every key
  • DraftMessage (IndexedDB): Written on blur or every 2s via debounce — survives tab close

I — Interface

Server APIs

// REST: Fetch conversations list on initial load
GET /api/conversations
→ { conversations: Conversation[], users: User[] }

// REST: Fetch message history for a conversation
GET /api/conversations/{id}/messages?before={cursor}&limit=30
→ { messages: Message[], nextCursor: string | null }

// REST: Send a message
POST /api/conversations/{id}/messages
Body: { clientId: string, content: string }
→ { message: Message }

WebSocket Events (server → client)

// New message received from another user
{ type: 'incoming_message', payload: Message }

// Your outgoing message was received by server
{ type: 'message_sent', payload: { clientId: string, messageId: string } }

// Message delivered to recipient's device
{ type: 'message_delivered', payload: { messageId: string } }

// Recipient read the message
{ type: 'message_read', payload: { messageId: string, readAt: string } }

// Sync event on reconnect — server pushes missed data
{ type: 'sync', payload: { conversations: Conversation[], messages: Message[] } }

Component Interface

interface MessageComposerProps {
  conversationId: string;
  onSend: (content: string) => void;
  draft?: string;
  onDraftChange: (draft: string) => void;
}

interface MessageBubbleProps {
  message: Message;
  isMine: boolean;
}

O — Optimisations

1. Message Delivery States

Tracking delivery gives users confidence their message arrived. Five states:

StatusIndicatorMeaning
sendingClock / empty circleIn outgoing queue, not yet sent to server
sentSingle checkmarkServer acknowledged receipt
deliveredDouble checkmarkDelivered to recipient's device
readDouble blue checkmarkRecipient opened the conversation
failed⚠️ with retry optionFailed after max retries

Implementation:

// 1. User taps send
function sendMessage(conversationId: string, content: string) {
  const clientId = generateId();

  // Optimistic: write to IndexedDB immediately
  db.messages.add({ clientId, conversationId, content, status: 'sending', ... });
  db.sendMessageRequests.add({ clientId, status: 'pending', failCount: 0, ... });

  // Message Scheduler picks this up and sends to server
}

// 2. Server acknowledges via WebSocket
// { type: 'message_sent', payload: { clientId, messageId } }
db.messages.where({ clientId }).modify({ status: 'sent', id: messageId });
db.sendMessageRequests.where({ clientId }).delete();

2. Message Scheduler & Retry Logic

The scheduler monitors SendMessageRequests and processes them in order:

class MessageScheduler {
  async processQueue() {
    const pending = await db.sendMessageRequests
      .where('status').anyOf(['pending', 'failed'])
      .sortBy('createdAt');

    for (const req of pending) {
      if (req.failCount >= MAX_RETRIES) {
        await db.messages.where({ clientId: req.clientId }).modify({ status: 'failed' });
        await db.sendMessageRequests.where({ clientId: req.clientId }).delete();
        continue;
      }

      const backoffMs = Math.pow(2, req.failCount) * 1000; // 1s, 2s, 4s, 8s...
      if (req.lastAttemptAt && Date.now() - new Date(req.lastAttemptAt).getTime() < backoffMs) {
        continue; // Not ready to retry yet
      }

      await this.sendToServer(req);
    }
  }
}

3. Real-Time: WebSockets vs Alternatives

ApproachProsCons
Short pollingSimple, works everywhereLatency = poll interval; wasteful
Long pollingLower latency than pollingComplex; one connection per conversation
WebSocketTrue real-time, bidirectional, low overheadRequires persistent connection mgmt
SSESimple, built-in reconnectUnidirectional only

WebSocket is the right choice for chat. It's what Messenger, Slack, and Discord use. Key considerations:

  • Reconnect automatically on disconnect with exponential backoff
  • Send a sync request on reconnect to catch up on missed messages
  • Fall back to long-polling if WebSocket is blocked (corporate firewalls)
class DataSyncer {
  private ws: WebSocket | null = null;

  connect() {
    this.ws = new WebSocket(WS_URL);
    this.ws.onopen = () => this.onReconnect();
    this.ws.onmessage = (e) => this.handleEvent(JSON.parse(e.data));
    this.ws.onclose = () => this.scheduleReconnect();
  }

  private scheduleReconnect(attempt = 0) {
    const delay = Math.min(Math.pow(2, attempt) * 1000, 30000); // cap at 30s
    setTimeout(() => this.connect(), delay);
  }

  private async onReconnect() {
    // Tell server our last-known state so it can send missed messages
    const lastSyncAt = await db.meta.get('lastSyncAt');
    this.ws?.send(JSON.stringify({ type: 'sync', since: lastSyncAt }));
  }
}

4. Cross-Tab Sync with BroadcastChannel

All tabs share the same IndexedDB, but they don't automatically know when another tab modifies it. Use BroadcastChannel to notify sibling tabs:

const bc = new BroadcastChannel('chat-updates');

// When a new message is written to IndexedDB:
bc.postMessage({ type: 'new_message', conversationId, messageId });

// Other tabs listen:
bc.onmessage = ({ data }) => {
  if (data.type === 'new_message') {
    refreshConversation(data.conversationId);
  }
};

This prevents duplicate processing too — only the tab that receives the WebSocket event writes to DB and broadcasts; other tabs just refresh from DB.

5. Offline Detection & Queuing

window.addEventListener('online', () => {
  showToast('Back online — sending queued messages');
  messageScheduler.processQueue();
  dataSyncer.connect();
});

window.addEventListener('offline', () => {
  showBanner('You are offline. Messages will be sent when you reconnect.');
});

Messages typed while offline go into SendMessageRequests with status: 'pending'. The scheduler picks them up the moment the browser goes back online.

One caveat: Don't retry messages that are too old. If a draft was written 30 minutes ago while offline, the conversation may have moved on. Cap retries to messages created within the last 5 minutes.

6. Virtualised Message List

Long conversations (100s of messages) will kill scroll performance if all rendered. Use virtualisation:

import { useVirtualizer } from '@tanstack/react-virtual';

const virtualizer = useVirtualizer({
  count: messages.length,
  getScrollElement: () => listRef.current,
  estimateSize: () => 60,     // Estimated bubble height
  overscan: 10,
  // Reverse list — newest messages at bottom
  paddingStart: 20,
});

Tricky part: scroll anchoring. When new messages arrive at the bottom and the user is already scrolled to the bottom, auto-scroll to show them. When the user has scrolled up to read history, keep their position stable even as new messages append below.

const isAtBottom = listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight < 50;

// After appending new messages:
if (isAtBottom) {
  listEl.scrollTop = listEl.scrollHeight;
}

7. Deduplication

The same message can arrive via both the HTTP response (from a POST /messages) and the WebSocket event (message_sent). Use clientId as the idempotency key:

async function upsertMessage(msg: Message) {
  const existing = await db.messages.where({ clientId: msg.clientId }).first();
  if (existing) {
    await db.messages.where({ clientId: msg.clientId }).modify(msg);
  } else {
    await db.messages.add(msg);
  }
}

8. Draft Persistence

// In MessageComposer
const [draft, setDraft] = useState('');

// Flush to IndexedDB on blur or debounced
const saveDraft = useDebouncedCallback(async (content) => {
  if (content.trim()) {
    await db.draftMessages.put({ conversationId, content, savedAt: new Date().toISOString() });
  } else {
    await db.draftMessages.where({ conversationId }).delete();
  }
}, 1000);

// On mount, restore draft
useEffect(() => {
  db.draftMessages.get(conversationId).then(draft => {
    if (draft) setDraft(draft.content);
  });
}, [conversationId]);

9. Accessibility

// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
  if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowDown') nextConversation();
  if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowUp') prevConversation();
});

// Message composer
<textarea
  aria-label="Message composer"
  onKeyDown={(e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
    // Shift+Enter = newline
  }}
/>

// Message list
<div role="log" aria-live="polite" aria-label="Conversation messages">
  {/* New messages are announced by screen readers via live region */}
</div>

10. Progressive Web App (PWA)

Package the app as a PWA to:

  • Cache HTML/JS/CSS via Service Worker → loads instantly on repeat visits
  • Cache recent messages via the Service Worker → works fully offline
  • Enable browser push notifications → "New message from Alice"

Summary

A complete chat app system design covers:

  • Requirements: real-time messaging, offline browsing, delivery states, cross-tab consistency, draft persistence
  • Architecture: UI reads from IndexedDB (not server), Data Syncer handles WebSocket + REST, Message Scheduler manages outgoing queue
  • Data model: Message, Conversation, User, DraftMessage, SendMessageRequest in IndexedDB; clientId for deduplication
  • Interface: REST for initial load, WebSocket for real-time events (incoming_message, message_sent, delivered, read, sync)
  • Optimisations: WebSocket with exponential backoff reconnect, BroadcastChannel for cross-tab sync, exponential backoff retries, virtualised message list, scroll anchoring, optimistic UI, offline queue, PWA for push notifications

The distinguishing insight: treat the client database (IndexedDB) as the source of truth for the UI, not the server. This single architectural decision enables offline support, cross-tab consistency, and instant optimistic updates — all at once.

The Series: Frontend System Design Interview Questions