Go Guide

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. 011. Requirements
  2. 022. Estimates
  3. 033. How the client stays connected
  4. 044. High-level design
  5. 055. Ordering and catch-up
  6. 066. Deep dives
  7. 07Build it: a real-time chat server in Go
  8. 08Summary
  9. 09Check your understanding
  10. 10Practice in Go

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#

TechniqueHow it worksGood for
short pollingGET /messages?since=812 every few secondssimple, but slow or wasteful
long pollingthe server holds the request until there is newsworks everywhere; one request per batch
Server-Sent Eventsone HTTP response that never ends; the server writes events into itserver-to-client streams; auto-reconnect built into browsers
WebSocketan upgraded TCP connection, both sides send framesfull 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…
  1. A client connects to any gateway; the gateway records "user → gateway" in a registry (Redis) for presence and routing.
  2. 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.
  3. Every gateway with members of that room receives it from the bus and writes it to their sockets.
  4. 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:

catchup.go
Go
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)
	}
}
Output
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 INCR in 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 client

Step 1: module

Shell
mkdir chat && cd chat && go mod init chat
mkdir -p internal/chat web
go.mod
module chat
 
go 1.24

Step 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.

internal/chat/hub.go
Go
// 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]
	if !ok {
		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)
}
internal/chat/hub_test.go
Go
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>.

main.go
Go
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:
				if !ok {
					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.

main_test.go
Go
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()
	if !slices.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)
	}
	if !slices.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.

web/index.html
HTML
<!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

Shell
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.

  1. Q1.Why do chat apps keep a persistent connection (WebSocket or SSE) instead of having clients poll every second?

  2. Q2.Two people send messages to the same room at almost the same time. How do all clients agree on the order?

  3. Q3.A phone loses signal for 30 seconds. How does it get the messages it missed?

  4. Q4.Alice is connected to gateway 3, Bob to gateway 7. How does Alice's message reach Bob?

  5. 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.

  • 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 typing so 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.