advanced

Case Study: Messaging API

In-product chat: conversations, message history, read state, and live delivery to open clients.

Messaging forces a decision most APIs get to dodge: which operations belong on request/response and which need a persistent connection? The answer here is deliberately unglamorous — everything is REST except the one thing that can't be: learning that something happened *now*. Sends, history, read state are plain HTTP because they need retries, caching, and debuggability; a single WebSocket carries only wake-up events (WebSocket Message Contracts). The other defining problem is history pagination: messages arrive constantly, users scroll backwards, and offset pagination produces duplicated or skipped messages within seconds — the textbook case for cursors (Cursor Pagination: An Opaque Bookmark, Not a Position). Watch how often the design answer is "make the operation idempotent and let the client retry" rather than "make the network reliable".

Consumers

Web client

Instant delivery while a tab is open, infinite scroll backwards through years of history, and unread counts that match reality across devices.

Mobile client

Sends that survive tunnels and app suspension — a message tapped once must appear exactly once, no matter how many retries happened underneath.

Workflow bots

Server-side integrations that post notifications and read history over plain HTTP with an API key — no WebSocket, no session state.

Requirements

  • List a user's conversations ordered by recent activity, with last-message preview and unread count.
  • Send a message; a client retry after a timeout must never create a duplicate.
  • Paginate backwards through history that is being appended to *while* the user scrolls — no gaps, no duplicates.
  • Per-user read state, updated from any device, consistent across all of them.
  • Sub-second delivery of new messages to connected clients; disconnected clients catch up losslessly on reconnect.
  • Bots do everything except live delivery over stateless HTTP.

Resources

Conversation

The container and the authorization boundary — every message operation checks membership of the conversation, nothing else. Carries denormalized `last_message_at` because "order my conversations by activity" is the single hottest read.

Message

Immutable once accepted (edits create revisions), identified by a server-assigned id *and* a server-assigned position in the conversation's total order — that position is what makes cursor pagination and reconnect catch-up exact.

Membership

A user's relationship to a conversation, and the natural home of per-user state: `last_read_position`, notification level. Read state on the membership (not the message) means marking read is one write, not N.

Event

The realtime vocabulary: `message.created`, `read.updated`, each carrying the conversation position. Events are notifications *about* resources, deliberately thin — the REST resource remains the truth a client re-fetches when in doubt.

Operations

OperationPurposeDesign notes
GET /conversationsList the caller's conversations by recent activity.Cursor-paginated on (last_message_at, id). Embeds last-message preview and unread count — without that, rendering an inbox is 1 + 2N requests, the classic chattiness failure (Over-Fetching and Under-Fetching).
POST /conversationsCreate a conversation with initial members.For DMs, creation is *idempotent on the member pair*: two clients "starting a chat" with the same person concurrently converge on one conversation (200 with the existing one) instead of racing into two.
POST /conversations/{id}/messagesSend a message.Requires a client-generated client_key (UUID per send attempt-group). A retry with the same key returns the *original* message with 200 — the mobile tunnel scenario, solved in the contract rather than in every client (Idempotency vs Deduplication). Response includes the assigned position.
GET /conversations/{id}/messagesPage through history.Cursor on the position sequence, direction: older | newer, default newest-first. Position cursors are exact under concurrent appends: scrolling up while messages arrive below never skips or repeats — the property offset pagination cannot offer here at any price (Cursor Pagination: An Opaque Bookmark, Not a Position).
POST /conversations/{id}/readAdvance the caller's read position.A command carrying position, and *monotonic*: the server ignores moves backwards, so two devices racing (position: 118 after position: 120) can't make a conversation flip back to unread. Idempotent by construction — same position twice is a no-op.
GET /eventsWebSocket upgrade: live events for all of the caller's conversations.One connection per client, *not* one per conversation — connections are the scarce resource. On connect the client sends its last seen positions and receives a gap-fill, making reconnect lossless without a second sync protocol.
GET /conversations/{id}/membersList members and their roles.Paginated — group conversations grow, and member lists are the second unbounded collection hiding in every chat design (Unbounded Collections: The Anti-Pattern With a Fuse).
DELETE /conversations/{id}/messages/{msgId}Delete (tombstone) a message.Tombstone rather than removal: positions must stay dense for cursor math, and other clients need a message.deleted event referencing something. Body content is gone; the slot remains.

Error contract

CodeStatusWhenRetryable
NOT_PARTICIPANT403Any operation on a conversation the caller isn't a member of — one error for send, read, and list, because membership is the single boundary.no
MESSAGE_TOO_LARGE413Body exceeds 64 KB. Attachments go through the upload flow and are *referenced* — a messaging API is not a file transfer API.no
INVALID_CURSOR400Cursor is malformed or from an incompatible API version. Clients recover by restarting from the newest page — documented as the standard recovery, so nobody caches cursors as bookmarks.no
CONVERSATION_ARCHIVED409Sending into an archived conversation. Reads still work — the state gates writes only, and the error names the unarchive operation.no
RATE_LIMITED429Send rate exceeded (per-user, per-conversation). `Retry-After` set; bots get higher documented budgets on their keys.after delay
EVENT_STREAM_STALE410On WS reconnect, the client's positions are older than the gap-fill window (7 days). Instructs a full resync via REST — the contract admits the stream buffer is finite instead of silently dropping history.no

Decision log

Decision → reason → alternative → trade-off. The alternative is part of the record.

REST for every operation; one WebSocket used only as a wake-up channel.
Reason · Sends over WS need app-level acks, retries, and dedup — rebuilding HTTP badly inside a socket. Sends over HTTP get idempotency, load balancing, and curl-debuggability for free; the socket does the one thing HTTP can't: server push (Which API Style Should I Use?).
Alternative · Full WS protocol for send + receive (lower per-message latency, single connection).
Trade-off · Two transports to operate and document, and send latency pays an HTTP request — tens of milliseconds, invisible next to human typing time.
Server-assigned per-conversation positions as the ordering and cursor basis.
Reason · Client timestamps lie (clock skew) and server timestamps collide; a dense per-conversation sequence gives exact cursors, exact gap-fill on reconnect, and a total order every device agrees on.
Alternative · Timestamp-plus-id ordering (no sequencer needed).
Trade-off · Assigning positions serializes writes per conversation — a real ceiling (~thousands of msg/s per conversation) accepted because no human conversation reaches it.
Deduplication by client-generated key on send, required not optional.
Reason · The double-send is *the* messaging bug: mobile retry after an ambiguous timeout. Requiring the key makes every client safe by construction, and returning the original message (not an error) makes retry logic trivial.
Alternative · Optional keys, or server-side heuristic dedup (same sender + text within 5s).
Trade-off · A dedup store with a retention window (48h) to operate; heuristics were rejected because "yes" sent twice on purpose is legitimate.
Read state is a monotonic position on the membership, advanced by a command.
Reason · Multi-device is a race by nature; monotonicity makes the merge automatic and the operation idempotent. Per-message read receipts would be N writes per glance at a conversation.
Alternative · Per-message receipts (needed for per-message "seen by" in groups).
Trade-off · The contract can say "read up to here" but not "read exactly these" — a scoped promise, traded for one cheap write.
Events are thin notifications; REST is the source of truth clients re-fetch.
Reason · Fat events become a second full schema that must evolve in lockstep with REST — two contracts to break (Backward Compatibility: The Real Rules). Thin events plus documented re-fetch keeps one canonical shape.
Alternative · Fat events carrying full resources (fewer round trips on busy streams).
Trade-off · A burst of events triggers a burst of GETs; mitigated by carrying the full message body in message.created only — the one event where the follow-up fetch was universal.

How it evolves

  • Reactions arrive as a sub-resource (POST /messages/{id}/reactions) plus a reaction.updated event — old clients ignore the unknown event type by documented rule and simply don't render reactions; nothing breaks.
  • Threads reuse the conversation machinery: a thread is a conversation with a parent_message field. No new pagination, read-state, or event contracts — the payoff of resources modeled around behavior rather than UI (From Domain to Resources).
  • Edit history: messages gain revision and an edited_at; message.updated joins the event vocabulary. Immutability-plus-revisions was chosen in V1 partly because it makes this additive.
  • Presence and typing ship as a separate ephemeral event class over the existing socket, explicitly excluded from gap-fill — the contract distinguishes durable events (messages) from ephemeral ones (typing) so the reconnect promise stays honest (Server-Sent Events).

Lessons behind this design