Pick a depth. Each prompt opens in your AI pre-loaded with the lesson. Click a row to preview the prompt.
A direct message is two people and is not a distributed systems problem. A 50,000-member announcements channel is the same code path with a multiplier that changes the answer to every question. This is the same asymmetry that makes social feeds hard — the celebrity problem — but chat adds a constraint feeds do not have: a latency budget measured in hundreds of milliseconds. A feed can be lazy, precomputing a timeline when someone opens the app, because nobody knows when a post was written. Chat cannot: the recipient is looking at the screen right now and will notice a two-second delay. So chat cannot choose between fan-out on write and fan-out on read the way feed systems do. It must do both at once, split by a property that no feed system uses as its axis — whether the recipient currently has a socket open. Once you see that split, the 50,000-member channel stops being frightening: it is a few thousand real deliveries and forty-odd thousand people who cost you nothing at all until they open the app. Getting there requires three specific mechanisms, and the difference between them and the naive version is roughly two orders of magnitude of CPU.
The naive fan-out does three expensive things per recipient: it looks up whether they are online, it serialises the message payload, and it makes a network call. At 8,000 connected recipients that is 8,000 registry reads, 8,000 JSON encodes and 8,000 network sends for one message. All three are avoidable.
The registry read is avoidable because the connected set per channel can be maintained incrementally, on subscribe and unsubscribe, instead of recomputed per message. Subscriptions change thousands of times a second across the fleet; messages fan out tens of thousands of times a second. Maintaining the smaller stream is the cheaper direction.
The encode is avoidable because the payload is identical for every recipient. Encode once, hold the byte slice, write the same bytes to every socket. This single change is usually the largest CPU win in the entire system, and it is the reason the gateway API in challenge 2 takes a pre-serialised frame rather than a struct.
The network calls are avoidable because sessions cluster on gateway nodes. Twelve gateways holding 8,000 sessions means twelve messages carrying recipient lists, not 8,000 messages. Fan-out cost becomes a function of fleet size, not of channel size — which is the property that makes a 50,000-member channel and a 5,000-member channel cost nearly the same.
And the 42,000 members with no socket? Write nothing. Their badge is a subtraction against the channel head at the moment they open the app. The subset who should get a phone notification are handed to the push service, which is a different system with a thirty-second budget and its own delivery state machine — not this one’s problem.
// The fan-out planner. Note what it does NOT do: it never reads the
// channel membership list. Membership is 50,000 rows; the connected set
// is 8,000 entries and is maintained incrementally as sockets come and
// go. Fan-out is O(connected), never O(members).
type Plan struct {
ByGateway map[string][]string // gatewayID -> sessionIDs on that node
Live int
Offline bool // true if anyone in the channel has no session
}
type Registry interface {
// LiveSessions returns the incrementally-maintained connected set.
// One read per message, not one per member.
LiveSessions(channelID string) []SessionRef
}
type SessionRef struct {
SessionID string
UserID string
Gateway string
}
func PlanFanout(reg Registry, channelID string, senderSession string) Plan {
live := reg.LiveSessions(channelID)
p := Plan{ByGateway: make(map[string][]string, 16), Live: len(live)}
for _, s := range live {
if s.SessionID == senderSession {
continue // the sender already has it; it got the ack
}
p.ByGateway[s.Gateway] = append(p.ByGateway[s.Gateway], s.SessionID)
}
return p
}
// Dispatch: one encode, one send per GATEWAY, not per recipient.
func Dispatch(ctx context.Context, tx GatewayClient, m Message, p Plan) error {
frame, err := EncodeFrame(m) // ← exactly once, for all recipients
if err != nil {
return err
}
g, ctx := errgroup.WithContext(ctx)
for gw, sessions := range p.ByGateway {
gw, sessions := gw, sessions
g.Go(func() error {
// Batched: recipient list + one shared payload.
// A failure here is a LATENCY bug, not a correctness bug —
// those clients pick the message up on their next read or
// reconnect cursor. So: log, count, do not retry forever.
if err := tx.DeliverBatch(ctx, gw, sessions, frame); err != nil {
metrics.FanoutGatewayErrors.WithLabelValues(gw).Inc()
}
return nil
})
}
return g.Wait()
}go run main.go