System design · Lesson 59 of 62 · about 95 min
Design a chat system
Design WhatsApp- or Slack-style messaging: persistent connections (WebSockets and Server-Sent Events), per-room sequence numbers for ordering, catch-up after reconnects, fan-out across gateway servers, presence and offline delivery, then build a real-time chat server in Go with a browser client.
On this page · 10 sections
1. Requirements#
Functional: one-to-one and group conversations (rooms); messages appear for online members within a fraction of a second; history is kept and scrollable; users see who is online; offline users get a push notification.
Non-functional: low latency (under 200 ms end to end in the same region); messages are never lost and never reordered; a dropped connection recovers without user action; the system holds millions of concurrent connections.
2. Estimates#
50 million daily users sending 40 messages each: 2 billion messages a day, about 23,000 per second on average and maybe 100,000 at peak. At ~100 bytes each, that is 200 GB of new messages a day, so storage grows by about 70 TB a year before replication.
If 10 million people are connected at once and one gateway server holds 100,000 idle connections comfortably, you need about 100 gateway servers just to keep sockets open. Connections, not CPU, are the scaling unit.
3. How the client stays connected#
| Technique | How it works | Good for |
|---|---|---|
| short polling | GET /messages?since=812 every few seconds | simple, but slow or wasteful |
| long polling | the server holds the request until there is news | works everywhere; one request per batch |
| Server-Sent Events | one HTTP response that never ends; the server writes events into it | server-to-client streams; auto-reconnect built into browsers |
| WebSocket | an upgraded TCP connection, both sides send frames | full two-way traffic: games, typing indicators, calls |
Most chat apps use WebSockets. The build below uses SSE for receiving and plain POST for sending: everything is standard HTTP, Go's standard library supports it fully, and browsers reconnect automatically and tell the server the last event ID they saw. That built-in resume is exactly the catch-up mechanism chat needs.
4. High-level design#
Drawing diagram…
- A client connects to any gateway; the gateway records "user → gateway" in a registry (Redis) for presence and routing.
- A sent message goes to the chat service, which assigns the room's next sequence number, writes it to the message store, and publishes it.
- Every gateway with members of that room receives it from the bus and writes it to their sockets.
- Members who are not connected get a push through the notification system from the previous lesson.
5. Ordering and catch-up#
Sequence numbers solve three problems at once: ordering (sort by seq), gaps (seq 815 after 812 means two are missing), and resume (ask for everything after the last seq you have). Retries are made safe by a client message ID: if the phone resends because it never got the acknowledgement, the server recognises the ID and returns the original message instead of storing a copy.
Drawing diagram…
The same exchange in code:
package main
import "fmt"
type message struct {
seq int
text string
}
// room keeps an ordered history; since(n) is what a reconnecting client asks for.
type room struct{ history []message }
func (r *room) post(text string) message {
m := message{seq: len(r.history) + 1, text: text}
r.history = append(r.history, m)
return m
}
func (r *room) since(seq int) []message { return r.history[seq:] }
func main() {
r := &room{}
lastSeen := 0
for _, t := range []string{"hi", "anyone here?", "yes!"} {
lastSeen = r.post(t).seq // the phone is online and receives these
}
fmt.Println("phone saw up to seq", lastSeen, "then lost signal")
r.post("did you see the game?")
r.post("unbelievable ending")
for _, m := range r.since(lastSeen) {
fmt.Printf("replayed #%d %q\n", m.seq, m.text)
}
}phone saw up to seq 3 then lost signal
replayed #4 "did you see the game?"
replayed #5 "unbelievable ending"6. Deep dives#
- Storage. Messages are written once and read by range, newest first. Wide-column stores (Cassandra, ScyllaDB, as Discord uses) partition by room and sort by seq, so "the last 50 in room X" and "everything after 812" are both one contiguous read. Very large rooms are split into time buckets so one partition does not grow forever.
- Sequence numbers at scale. One counter per room is a tiny amount of state; route every write for a room to the same chat-service shard (consistent hashing on room ID) and keep the counter there, or use an atomic
INCRin Redis. - Slow consumers. A gateway keeps a small buffer per connection. If it fills, disconnect that client: it reconnects and catches up from storage. Never let one slow phone hold memory for a busy room.
- Presence. Each gateway heartbeats the users it holds; a user is online while a heartbeat is fresh. Broadcasting every presence change to every contact is expensive, so large apps fetch presence lazily when a conversation is opened.
- Huge rooms. A channel with 100,000 members turns one message into 100,000 deliveries. Fan out per gateway (one publish per gateway, not per user) and let clients of very large rooms fetch history on demand.
- End-to-end encryption. With E2E (Signal protocol, as WhatsApp uses), servers store and route ciphertext only; the same design applies, but search and moderation move to the client.
Build it: a real-time chat server in Go#
You will build a chat hub with rooms, per-room sequence numbers, bounded history, deduplication of resent messages, slow-consumer protection and presence, then an HTTP API that streams messages over Server-Sent Events, and a browser client that reconnects and catches up on its own.
chat/
├── go.mod
├── main.go HTTP API: post, SSE stream, online list
├── main_test.go end-to-end SSE tests
├── internal/chat/hub.go rooms, sequence numbers, fan-out
├── internal/chat/hub_test.go
└── web/index.html the chat clientStep 1: module
mkdir chat && cd chat && go mod init chat
mkdir -p internal/chat webmodule chat
go 1.24Step 2: the hub
One mutex guards all rooms. Posting is quick (append and a few non-blocking channel sends), so a single lock goes a long way; a production version would shard rooms across several hubs.
// Package chat implements rooms with ordered, replayable history and live subscribers.
package chat
import (
"errors"
"slices"
"sync"
"time"
"unicode/utf8"
)
// MaxText is the longest message accepted, in characters.
const MaxText = 2000
// Message is one chat message. Seq is its position in the room, starting at 1.
type Message struct {
Seq int64 `json:"seq"`
Room string `json:"room"`
From string `json:"from"`
Text string `json:"text"`
At time.Time `json:"at"`
ClientID string `json:"client_id,omitempty"`
}
var ErrInvalid = errors.New("message must have a sender and 1 to 2000 characters of text")
type room struct {
seq int64
history []Message // oldest first, at most historyLimit entries
byClient map[string]Message // client ID -> message, for messages still in history
subs map[*Sub]struct{}
}
// Sub is a live subscription. Messages arrive on C; C is closed when the subscription
// ends, either by Close or because the subscriber fell too far behind.
type Sub struct {
C <-chan Message
c chan Message
room *room
user string
hub *Hub
closed bool
}
// Hub holds every room.
type Hub struct {
mu sync.Mutex
rooms map[string]*room
historyLimit int
bufSize int
now func() time.Time
}
// NewHub keeps the last historyLimit messages per room and buffers bufSize messages per subscriber.
func NewHub(historyLimit, bufSize int, now func() time.Time) *Hub {
if now == nil {
now = time.Now
}
return &Hub{rooms: map[string]*room{}, historyLimit: historyLimit, bufSize: bufSize, now: now}
}
func (h *Hub) roomLocked(name string) *room {
r, ok := h.rooms[name]
ifok {
r = &room{byClient: map[string]Message{}, subs: map[*Sub]struct{}{}}
h.rooms[name] = r
}
return r
}
// Post stores a message with the room's next sequence number and delivers it to subscribers.
// Resending with the same clientID returns the original message instead of storing a duplicate.
func (h *Hub) Post(roomName, from, text, clientID string) (Message, error) {
if from == "" text == "" utf8.RuneCountInString(text) > MaxText {
return Message{}, ErrInvalid
}
h.mu.Lock()
defer h.mu.Unlock()
r := h.roomLocked(roomName)
if m, ok := r.byClient[clientID]; ok clientID != "" {
return m, nil
}
r.seq++
m := Message{Seq: r.seq, Room: roomName, From: from, Text: text, At: h.now().UTC(), ClientID: clientID}
r.history = append(r.history, m)
if clientID != "" {
r.byClient[clientID] = m
}
if len(r.history) > h.historyLimit {
old := r.history[0]
r.history = slices.Delete(r.history, 0, 1)
delete(r.byClient, old.ClientID)
}
for s := range r.subs {
select {
case s.c <- m:
default:
// Too slow: disconnect rather than buffer without limit. The client reconnects
// with its last seq and catches up from history.
s.closeLocked()
}
}
return m, nil
}
// Subscribe returns the stored messages after seq since (the catch-up) and a live
// subscription for everything newer. Holding the lock across both means no message
// can fall between the backlog and the subscription.
func (h *Hub) Subscribe(roomName, user string, since int64) ([]Message, *Sub) {
h.mu.Lock()
defer h.mu.Unlock()
r := h.roomLocked(roomName)
i, _ := slices.BinarySearchFunc(r.history, since+1, func(m Message, seq int64) int { return int(m.Seq - seq) })
backlog := slices.Clone(r.history[i:])
c := make(chan Message, h.bufSize)
s := &Sub{C: c, c: c, room: r, user: user, hub: h}
r.subs[s] = struct{}{}
return backlog, s
}
// Close ends the subscription. It is safe to call more than once.
func (s *Sub) Close() {
s.hub.mu.Lock()
defer s.hub.mu.Unlock()
s.closeLocked()
}
func (s *Sub) closeLocked() {
if s.closed {
return
}
s.closed = true
delete(s.room.subs, s)
close(s.c)
}
// Online lists the distinct users subscribed to a room, sorted.
func (h *Hub) Online(roomName string) []string {
h.mu.Lock()
defer h.mu.Unlock()
users := []string{}
if r, ok := h.rooms[roomName]; ok {
for s := range r.subs {
users = append(users, s.user)
}
}
slices.Sort(users)
return slices.Compact(users)
}package chat
import (
"errors"
"slices"
"strings"
"testing"
"time"
)
func newHub(limit, buf int) *Hub {
t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
return NewHub(limit, buf, func() time.Time { return t0 })
}
func texts(ms []Message) []string {
var out []string
for _, m := range ms {
out = append(out, m.Text)
}
return out
}
func TestSequenceAndCatchUp(t *testing.T) {
h := newHub(100, 10)
for _, s := range []string{"a", "b", "c", "d"} {
h.Post("go", "ann", s, "")
}
m, _ := h.Post("rust", "ann", "other room", "")
if m.Seq != 1 {
t.Errorf("each room counts from 1, got %d", m.Seq)
}
backlog, sub := h.Subscribe("go", "bob", 2)
defer sub.Close()
if got := texts(backlog);slices.Equal(got, []string{"c", "d"}) {
t.Fatalf("catch-up after 2 = %v", got)
}
h.Post("go", "ann", "e", "")
if live := <-sub.C; live.Seq != 5 live.Text != "e" {
t.Errorf("live message = %+v", live)
}
}
func TestHistoryLimit(t *testing.T) {
h := newHub(3, 10)
for _, s := range []string{"1", "2", "3", "4", "5"} {
h.Post("r", "ann", s, "")
}
backlog, sub := h.Subscribe("r", "bob", 0)
sub.Close()
if got := texts(backlog);slices.Equal(got, []string{"3", "4", "5"}) {
t.Errorf("history = %v, want the last 3", got)
}
}
func TestResendIsDeduplicated(t *testing.T) {
h := newHub(100, 10)
first, _ := h.Post("r", "ann", "hello", "c-1")
again, _ := h.Post("r", "ann", "hello", "c-1")
if again.Seq != first.Seq {
t.Errorf("resend got seq %d, want the original %d", again.Seq, first.Seq)
}
backlog, sub := h.Subscribe("r", "bob", 0)
sub.Close()
if len(backlog) != 1 {
t.Errorf("stored %d messages, want 1", len(backlog))
}
}
func TestSlowSubscriberIsDropped(t *testing.T) {
h := newHub(100, 1)
_, slow := h.Subscribe("r", "slow", 0)
_, fast := h.Subscribe("r", "fast", 0)
h.Post("r", "ann", "one", "")
<-fast.C // fast keeps up
h.Post("r", "ann", "two", "") // slow's buffer of 1 is full: it gets disconnected
<-fast.C
if m := <-slow.C; m.Text != "one" {
t.Errorf("buffered message = %q", m.Text)
}
if _, ok := <-slow.C; ok {
t.Error("slow subscriber should be closed")
}
if got := h.Online("r");slices.Equal(got, []string{"fast"}) {
t.Errorf("online = %v", got)
}
slow.Close() // already closed by the hub: must not panic
fast.Close()
}
func TestOnlineAndValidation(t *testing.T) {
h := newHub(100, 10)
_, a1 := h.Subscribe("r", "ann", 0)
_, a2 := h.Subscribe("r", "ann", 0) // two tabs
_, b := h.Subscribe("r", "bob", 0)
if got := h.Online("r");slices.Equal(got, []string{"ann", "bob"}) {
t.Errorf("online = %v", got)
}
a1.Close()
a2.Close()
b.Close()
if got := h.Online("r"); len(got) != 0 {
t.Errorf("online after close = %v", got)
}
for _, bad := range []string{"", strings.Repeat("x", MaxText+1)} {
if _, err := h.Post("r", "ann", bad, "");errors.Is(err, ErrInvalid) {
t.Errorf("text of %d chars: err = %v", len(bad), err)
}
}
}Step 3: the HTTP API with Server-Sent Events
An SSE response is a normal HTTP response with Content-Type: text/event-stream that the server never finishes. Each event is a few field: value lines and a blank line. Setting id: to the sequence number is what makes resume work: when the connection drops, the browser reconnects by itself and sends Last-Event-ID: <seq>.
package main
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"strconv"
"time"
"chat/internal/chat"
)
//go:embed web/index.html
var indexHTML []byte
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func newServer(hub *chat.Hub, heartbeat time.Duration) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexHTML)
})
mux.HandleFunc("POST /api/rooms/{room}/messages", func(w http.ResponseWriter, r *http.Request) {
var in struct {
From string `json:"from"`
Text string `json:"text"`
ClientID string `json:"client_id"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<14)).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
m, err := hub.Post(r.PathValue("room"), in.From, in.Text, in.ClientID)
if errors.Is(err, chat.ErrInvalid) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, m)
})
mux.HandleFunc("GET /api/rooms/{room}/online", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, hub.Online(r.PathValue("room")))
})
mux.HandleFunc("GET /api/rooms/{room}/events", func(w http.ResponseWriter, r *http.Request) {
// The browser sends Last-Event-ID on automatic reconnects; ?since= covers the first connect.
since, _ := strconv.ParseInt(r.Header.Get("Last-Event-ID"), 10, 64)
if since == 0 {
since, _ = strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
}
user := r.URL.Query().Get("user")
if user == "" {
user = "anonymous"
}
backlog, sub := hub.Subscribe(r.PathValue("room"), user, since)
defer sub.Close()
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
h.Set("X-Accel-Buffering", "no") // tell nginx-style proxies not to buffer the stream
rc := http.NewResponseController(w)
send := func(m chat.Message) error {
b, _ := json.Marshal(m)
if _, err := fmt.Fprintf(w, "id: %d\nevent: message\ndata: %s\n\n", m.Seq, b); err != nil {
return err
}
return rc.Flush()
}
fmt.Fprint(w, "retry: 1000\n\n") // reconnect after 1s if the stream breaks
for _, m := range backlog {
if send(m) != nil {
return
}
}
rc.Flush()
ping := time.NewTicker(heartbeat) // keeps idle connections open through proxies
defer ping.Stop()
for {
select {
case <-r.Context().Done():
return
case m, ok := <-sub.C:
ifok {
return // dropped as a slow consumer: the client reconnects and catches up
}
if send(m) != nil {
return
}
case <-ping.C:
fmt.Fprint(w, ": ping\n\n")
if rc.Flush() != nil {
return
}
}
}
})
return mux
}
func main() {
hub := chat.NewHub(500, 64, nil)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
slog.Info("listening", "port", port)
// No WriteTimeout: SSE responses are meant to stay open.
srv := &http.Server{Addr: ":" + port, Handler: newServer(hub, 25*time.Second), ReadHeaderTimeout: 5 * time.Second}
if err := srv.ListenAndServe(); err != nil {
slog.Error("server", "err", err)
os.Exit(1)
}
}The end-to-end test starts a real server with httptest.NewServer, opens an event stream, posts a message, and reads it back from the stream. It also checks that Last-Event-ID replays only what was missed.
package main
import (
"bufio"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"chat/internal/chat"
)
// readEvents returns the data of the next n "message" events on the stream.
func readEvents(t *testing.T, sc *bufio.Scanner, n int) []chat.Message {
t.Helper()
var out []chat.Message
for len(out) < n sc.Scan() {
if data, ok := strings.CutPrefix(sc.Text(), "data: "); ok {
var m chat.Message
if err := json.Unmarshal([]byte(data), &m); err != nil {
t.Fatal(err)
}
out = append(out, m)
}
}
if len(out) < n {
t.Fatalf("stream ended after %d of %d events: %v", len(out), n, sc.Err())
}
return out
}
func openStream(t *testing.T, url, lastID string) *bufio.Scanner {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Cleanup(cancel)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
if lastID != "" {
req.Header.Set("Last-Event-ID", lastID)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { res.Body.Close() })
if ct := res.Header.Get("Content-Type"); ct != "text/event-stream" {
t.Fatalf("Content-Type = %q", ct)
}
return bufio.NewScanner(res.Body)
}
func post(t *testing.T, base, room, body string) *http.Response {
t.Helper()
res, err := http.Post(base+"/api/rooms/"+room+"/messages", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
res.Body.Close()
return res
}
func TestLiveStream(t *testing.T) {
srv := httptest.NewServer(newServer(chat.NewHub(100, 16, nil), time.Minute))
t.Cleanup(srv.Close) // runs after the stream cleanups, so no connection is left open
sc := openStream(t, srv.URL+"/api/rooms/go/events?user=bob", "")
if res := post(t, srv.URL, "go", `{"from":"ann","text":"hello bob","client_id":"c1"}`); res.StatusCode != 201 {
t.Fatalf("post: %d", res.StatusCode)
}
got := readEvents(t, sc, 1)[0]
if got.Seq != 1 got.From != "ann" got.Text != "hello bob" {
t.Errorf("event = %+v", got)
}
res, _ := http.Get(srv.URL + "/api/rooms/go/online")
var online []string
json.NewDecoder(res.Body).Decode(&online)
res.Body.Close()
ifslices.Equal(online, []string{"bob"}) {
t.Errorf("online = %v", online)
}
}
func TestResumeWithLastEventID(t *testing.T) {
srv := httptest.NewServer(newServer(chat.NewHub(100, 16, nil), time.Minute))
t.Cleanup(srv.Close)
for _, text := range []string{"one", "two", "three"} {
post(t, srv.URL, "go", `{"from":"ann","text":"`+text+`"}`)
}
sc := openStream(t, srv.URL+"/api/rooms/go/events", "1")
var got []string
for _, m := range readEvents(t, sc, 2) {
got = append(got, m.Text)
}
ifslices.Equal(got, []string{"two", "three"}) {
t.Errorf("replayed %v, want [two three]", got)
}
}
func TestRejectsBadMessages(t *testing.T) {
srv := httptest.NewServer(newServer(chat.NewHub(100, 16, nil), time.Minute))
t.Cleanup(srv.Close)
for _, body := range []string{`{"from":"ann","text":""}`, `{"text":"no sender"}`, `nope`} {
if res := post(t, srv.URL, "go", body); res.StatusCode != 400 {
t.Errorf("%s: %d, want 400", body, res.StatusCode)
}
}
}Step 4: the client
EventSource is the browser's built-in SSE client. It reconnects on its own and sends Last-Event-ID, so the page has no reconnect logic at all. The page still ignores any message whose seq it has already shown, because a replay can overlap with what arrived just before the drop.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chat</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; }
input, button { padding: .5rem .7rem; border-radius: 8px; border: 1px solid #999; font: inherit; }
button { background: #fff; cursor: pointer; }
#wrap { display: grid; grid-template-columns: 1fr 160px; gap: 1rem; margin-top: 1rem; }
#log { height: 420px; overflow-y: auto; border: 1px solid #ddd; border-radius: 10px; padding: .75rem; }
.m { margin: .3rem 0; } .m b { margin-right: .4rem; } .m small { color: #888; margin-right: .4rem; font-family: monospace; }
#online { border: 1px solid #ddd; border-radius: 10px; padding: .75rem; font-size: 14px; }
#state { font-size: 13px; color: #888; }
form { display: flex; gap: .5rem; margin-top: .75rem; } form input { flex: 1; }
</style>
</head>
<body>
<h1>Chat</h1>
<p>
Room <input id="room" value="general" size="10">
Name <input id="nick" size="10">
<button id="join">Join</button>
<span id="state">not connected</span>
</p>
<div id="wrap">
<div>
<div id="log"></div>
<form id="send"><input id="text" placeholder="Message" autocomplete="off" disabled><button disabled>Send</button></form>
</div>
<div id="online"><b>Online</b><div id="who"></div></div>
</div>
<script>
nick.value = "user" + Math.floor(Math.random() * 1000);
let es = null, lastSeq = 0, timer = null;
function show(m) {
if (m.seq <= lastSeq) return; // already shown (replay overlap)
lastSeq = m.seq;
const d = document.createElement("div");
d.className = "m";
const seq = document.createElement("small"); seq.textContent = "#" + m.seq;
const who = document.createElement("b"); who.textContent = m.from;
d.append(seq, who, document.createTextNode(m.text));
log.append(d);
log.scrollTop = log.scrollHeight;
}
async function refreshOnline() {
const users = await (await fetch(`/api/rooms/${encodeURIComponent(room.value)}/online`)).json();
who.textContent = users.join(", ") "nobody";
}
document.getElementById("join").onclick = () => {
if (es) es.close();
clearInterval(timer);
log.innerHTML = ""; lastSeq = 0;
es = new EventSource(`/api/rooms/${encodeURIComponent(room.value)}/events?user=${encodeURIComponent(nick.value)}`);
es.addEventListener("message", (e) => show(JSON.parse(e.data)));
es.onopen = () => { state.textContent = "connected"; refreshOnline(); };
es.onerror = () => { state.textContent = "reconnecting…"; };
text.disabled = false; send.querySelector("button").disabled = false; text.focus();
timer = setInterval(refreshOnline, 3000);
};
send.onsubmit = async (e) => {
e.preventDefault();
if (text.value.trim()) return;
const body = { from: nick.value, text: text.value, client_id: crypto.randomUUID() };
text.value = "";
// A real client retries this POST with the same client_id until it gets a 201.
await fetch(`/api/rooms/${encodeURIComponent(room.value)}/messages`, { method: "POST", body: JSON.stringify(body) });
};
</script>
</body>
</html>Step 5: run it
go test -race ./...
go run .
# open http://localhost:8080 in two windows, join the same room and talk
# watch the raw stream from a terminal:
curl -N "localhost:8080/api/rooms/general/events?user=terminal"
# and post from another one:
curl -X POST localhost:8080/api/rooms/general/messages -d '{"from":"curl","text":"hi from the terminal"}'Stop the server while a window is open, start it again, and post: the page shows "reconnecting…", then picks up. (History lives in memory, so after a restart it starts empty; persistent storage is the first production step.)
Step 6: toward production
- Persist messages in a store partitioned by room and sorted by seq, and serve catch-up from it when a client is further behind than the in-memory history.
- Several gateways: publish each posted message to Redis pub/sub (channel per room) and have every gateway deliver to its local subscribers. Keep seq assignment in one place per room.
- Authentication: take the user from a session cookie or token, not a query parameter.
- Offline push: when a member of the room has no live subscription, hand the message to the notification service.
- WebSockets when you need high-rate client-to-server traffic (typing indicators, games); the hub stays the same.
Summary#
- Persistent connections (WebSockets or SSE) push messages instantly; gateways hold sockets and do little else.
- A per-room sequence number gives one order for everyone, reveals gaps, and makes resume a range query.
- Client message IDs make resends safe; subscribing must be atomic with reading the backlog.
- Drop slow consumers instead of buffering without limit; replayable history makes that safe.
- Scale out with a pub/sub bus between gateways, and send offline members a push notification.
Check your understanding
Answer before scrolling back up. If you miss one, reread the matching section, then try again.
Q1.Why do chat apps keep a persistent connection (WebSocket or SSE) instead of having clients poll every second?
Q2.Two people send messages to the same room at almost the same time. How do all clients agree on the order?
Q3.A phone loses signal for 30 seconds. How does it get the messages it missed?
Q4.Alice is connected to gateway 3, Bob to gateway 7. How does Alice's message reach Bob?
Q5.A client's connection is too slow to keep up with a busy room. What should the server do?
Practice in Go
Write a table test first, then the solution. On LeetCode, choose Go as the language. Open a hint only after 15 minutes stuck.
- Design Twitter ↗medium
Show hint
Merging per-user timelines by time is the same as merging chat history from several rooms.
Show hint
An inbox across rooms is k sorted streams; a heap merges them.
- Typing indicatorsbuild
Add a POST /api/rooms/{room}/typing endpoint and an SSE event type
typingso other members see "Sam is typing…" for 3 seconds. Typing events must not be stored in history. - Read receiptsbuild
Track the highest sequence number each user has read per room. Expose it over the API and show "seen by 3" under messages in the page. Test that receipts only move forward.
Finished this lesson?
Mark it done to track your progress. It is saved in this browser only.