Real-Time Forum CLI in Go: TCP, TLS & Zero Dependencies
Step-by-step guide to a 100% CLI real-time chat forum in Go: TCP, TLS, UUID-deduped broadcast, resilient reconnection, and zero dependencies.
A Forum, Not a Private Chat
We’re building something different from the usual notes app: a real-time community forum, in the terminal. Anyone who connects sees every message from everyone. No private rooms, no direct messages, no database — when you shut down the server, the conversation disappears with it. It’s, on purpose, as simple as a public square.
But “simple” as an outcome doesn’t mean “simple” to build. A real-time forum done right has to solve real concurrent-systems problems: what happens if two people connect with the same name? What happens if one client is so slow it blocks everyone else? What happens if the network drops halfway through a message? How do you stop that same message from being delivered twice if the client retries?
This guide answers every one of those questions with code, not theory. By the end you’ll have a server (forumd) and a terminal client (forum), talking over TCP with TLS, without a single external package — zero dependencies, entirely standard library Go.
What We’re Building
- A general forum: every message is broadcast to everyone connected
- Every message carries a UUID generated by the client — the server drops duplicates
- Unique usernames, resolved automatically on collision
- Encrypted transport with TLS 1.3, real certificate verification (no
InsecureSkipVerify) - A resilient client: automatic reconnection with exponential backoff and jitter
- A resilient server: per-client rate limiting, slow-client eviction, heartbeats
- Zero persistence — everything lives in memory, everything is lost when the server stops
- Zero external dependencies — no
go.sum, standard library only - 100% CLI: no HTTP, no browser, no WebSocket — plain TCP sockets
go version
# go version go1.27.1 linux/amd64 (or newer)
Architecture: One Hub, No Mutex
The central piece is the Hub: a single goroutine that’s the sole owner of all state — the list of connected clients, the names in use, the IDs of messages already seen. Nobody else touches that state directly. Everyone talks to the Hub through channels.
This isn’t an aesthetic choice. It’s the literal application of Go’s motto: don’t communicate by sharing memory, share memory by communicating. Since only one goroutine ever reads or writes the internal maps, the entire project doesn’t need a single sync.Mutex.
graph LR
subgraph "Clients"
C1["forum CLI<br/>alice"]
C2["forum CLI<br/>bob"]
C3["forum CLI<br/>carol"]
end
subgraph "forumd"
L["TLS Listener"]
H["Hub<br/>single goroutine<br/>owns all state"]
D1["alice connection<br/>readPump / writePump"]
D2["bob connection<br/>readPump / writePump"]
D3["carol connection<br/>readPump / writePump"]
end
C1 <-->|"TCP + TLS"| L
C2 <-->|"TCP + TLS"| L
C3 <-->|"TCP + TLS"| L
L --> D1
L --> D2
L --> D3
D1 <-->|"channels"| H
D2 <-->|"channels"| H
D3 <-->|"channels"| H
Every accepted connection runs in its own goroutine, split into two classic halves: readPump (reads from the socket, validates, sends to the Hub) and writePump (receives from the Hub, writes to the socket). The Hub is the only meeting point between all of them.
Setting Up the Project
mkdir forum && cd forum
go mod init forum
You’re not going to install anything else. Really — no go get. Everything you need is already in the standard library: net, crypto/tls, bufio, encoding/json, context, log/slog.
mkdir -p cmd/forumd cmd/forum
mkdir -p internal/chat internal/server internal/client
mkdir -p certs
Final tree:
forum/
├── cmd/
│ ├── forumd/
│ │ └── main.go
│ └── forum/
│ └── main.go
├── internal/
│ ├── chat/
│ │ ├── message.go
│ │ ├── id.go
│ │ └── message_test.go
│ ├── server/
│ │ ├── hub.go
│ │ ├── client.go
│ │ ├── limiter.go
│ │ └── hub_test.go
│ └── client/
│ └── session.go
├── certs/
│ ├── server.crt
│ └── server.key
├── go.mod
├── Dockerfile
├── docker-compose.yml
└── Makefile
Notice: there’s no go.sum in this tree. With zero external dependencies, there’s nothing to sum.
Step 1: The Protocol — Newline-Delimited Messages
Before you write a single socket, define how client and server talk to each other. We’ll use newline-delimited JSON (NDJSON): every message is one JSON object on one line, terminated by \n. It’s readable at a glance, needs no custom binary framing, and bufio.Scanner splits it for free.
internal/chat/message.go
package chat
import (
"encoding/json"
"time"
)
type Kind string
const (
KindHello Kind = "hello"
KindWelcome Kind = "welcome"
KindMessage Kind = "message"
KindSystem Kind = "system"
KindPing Kind = "ping"
KindPong Kind = "pong"
)
const MaxBodyBytes = 2000
type Envelope struct {
Kind Kind `json:"kind"`
ID string `json:"id,omitempty"`
From string `json:"from,omitempty"`
Body string `json:"body,omitempty"`
SentAt time.Time `json:"sent_at"`
}
func Encode(e Envelope) ([]byte, error) {
b, err := json.Marshal(e)
if err != nil {
return nil, err
}
return append(b, '\n'), nil
}
func Decode(line []byte) (Envelope, error) {
var e Envelope
err := json.Unmarshal(line, &e)
return e, err
}
Six message kinds cover the entire protocol: hello (the client introduces itself), welcome (the server confirms the assigned name), message (real chat), system (join/leave notices), and ping/pong (the heartbeat that keeps the connection alive).
internal/chat/id.go
package chat
import (
"crypto/rand"
"fmt"
)
func NewID() string {
var b [16]byte
_, _ = rand.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
A real UUID v4, generated with crypto/rand, in 12 lines — no need to pull in github.com/google/uuid. A UUID v4 is 16 random bytes with six fixed bits (version and variant); that’s literally all this function does.
internal/chat/message_test.go
package chat
import "testing"
func TestEncodeDecodeRoundTrip(t *testing.T) {
original := Envelope{Kind: KindMessage, ID: NewID(), From: "omar", Body: "hello forum"}
raw, err := Encode(original)
if err != nil {
t.Fatalf("Encode() error = %v", err)
}
decoded, err := Decode(raw)
if err != nil {
t.Fatalf("Decode() error = %v", err)
}
if decoded.ID != original.ID || decoded.Body != original.Body {
t.Fatalf("round trip mismatch: got %+v, want %+v", decoded, original)
}
}
func TestNewIDIsUnique(t *testing.T) {
seen := make(map[string]bool)
for range 1000 {
id := NewID()
if seen[id] {
t.Fatalf("duplicate ID generated: %s", id)
}
seen[id] = true
}
}
for range 1000 — genuinely modern Go: ranging over an integer with no index variable to declare, available since Go 1.22.
Step 2: The Hub — The Server’s Heart
The Hub knows nothing about TCP. It knows nothing about TLS. It only knows abstract clients with a name and an outbound channel. That separation is what lets you reason about it without thinking about sockets at all.
internal/server/hub.go
package server
import (
"fmt"
"log/slog"
"time"
"forum/internal/chat"
)
type joinRequest struct {
desired string
client *client
done chan struct{}
}
type Hub struct {
joins chan joinRequest
leaves chan *client
broadcast chan chat.Envelope
clients map[*client]struct{}
names map[string]struct{}
seen map[string]time.Time
}
func NewHub() *Hub {
return &Hub{
joins: make(chan joinRequest),
leaves: make(chan *client),
broadcast: make(chan chat.Envelope, 256),
clients: make(map[*client]struct{}),
names: make(map[string]struct{}),
seen: make(map[string]time.Time),
}
}
func (h *Hub) Join(desired string) *client {
c := &client{id: chat.NewID(), send: make(chan chat.Envelope, 16)}
req := joinRequest{desired: desired, client: c, done: make(chan struct{})}
h.joins <- req
<-req.done
return c
}
func (h *Hub) Leave(c *client) {
h.leaves <- c
}
func (h *Hub) Broadcast(env chat.Envelope) {
h.broadcast <- env
}
func (h *Hub) Run() {
cleanup := time.NewTicker(time.Minute)
defer cleanup.Stop()
for {
select {
case req := <-h.joins:
username := h.uniqueName(req.desired)
req.client.username = username
h.clients[req.client] = struct{}{}
h.names[username] = struct{}{}
close(req.done)
slog.Info("client joined", "username", username, "id", req.client.id)
h.deliver(chat.Envelope{
Kind: chat.KindSystem,
Body: username + " joined the forum",
SentAt: time.Now().UTC(),
})
case c := <-h.leaves:
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
delete(h.names, c.username)
close(c.send)
slog.Info("client left", "username", c.username, "id", c.id)
h.deliver(chat.Envelope{
Kind: chat.KindSystem,
Body: c.username + " left the forum",
SentAt: time.Now().UTC(),
})
}
case env := <-h.broadcast:
if env.ID != "" {
if _, dup := h.seen[env.ID]; dup {
continue
}
h.seen[env.ID] = time.Now()
}
h.deliver(env)
case now := <-cleanup.C:
for id, at := range h.seen {
if now.Sub(at) > 5*time.Minute {
delete(h.seen, id)
}
}
}
}
}
func (h *Hub) deliver(env chat.Envelope) {
for c := range h.clients {
select {
case c.send <- env:
default:
slog.Warn("dropping slow client", "username", c.username)
delete(h.clients, c)
delete(h.names, c.username)
close(c.send)
}
}
}
func (h *Hub) uniqueName(base string) string {
name := base
for n := 2; ; n++ {
if _, taken := h.names[name]; !taken {
return name
}
name = fmt.Sprintf("%s-%d", base, n)
}
}
Four decisions carry everything else:
Joinis a request, not a command. It sends ajoinRequestwith adonechannel and waits. That forces unique-name assignment to happen inside the Hub’sRun(), serialized, with no possible race even if two people connect with the same name in the same microsecond.- Deduplication lives in the same
select. EveryEnvelopethat reachesh.broadcastis checked againsth.seenbefore it goes out. If the ID has already been seen, it’s silently dropped — so a message resent by mistake (or retried by the client) never lands twice. delivernever blocks. Theselectwith adefaultbranch means: if a client’s channel is full (16 messages queued and not drained), that client is considered slow and gets evicted right there. One stuck client can’t freeze the whole room.- Cleanup of seen IDs uses a
time.Tickerin the sameselect. No separate goroutine needed for housekeeping — it’s just one more case in the same loop.
close(req.done) and <-req.done form a real synchronization barrier: everything the Hub wrote before closing that channel (including req.client.username) is guaranteed visible to whoever called Join() the moment it returns. No mutex needed to read c.username afterward.
Step 3: Per Connection — readPump and writePump
Every accepted connection gets its own pair of goroutines: one that reads, one that writes. They never talk to each other directly — they communicate with the Hub through channels, and coordinate their own shutdown through the send channel.
internal/server/limiter.go
package server
import "time"
type limiter struct {
tokens float64
max float64
rate float64
last time.Time
}
func newLimiter(maxTokens, perSecond float64) *limiter {
return &limiter{tokens: maxTokens, max: maxTokens, rate: perSecond, last: time.Now()}
}
func (l *limiter) allow() bool {
now := time.Now()
l.tokens = min(l.max, l.tokens+now.Sub(l.last).Seconds()*l.rate)
l.last = now
if l.tokens < 1 {
return false
}
l.tokens--
return true
}
A 15-line token bucket, with no sync.Mutex, because each instance lives inside a single client’s own goroutine — nobody else ever touches it.
internal/server/client.go
package server
import (
"bufio"
"net"
"strings"
"time"
"forum/internal/chat"
)
const (
readTimeout = 90 * time.Second
writeTimeout = 10 * time.Second
pingInterval = 30 * time.Second
)
type client struct {
id string
username string
conn net.Conn
send chan chat.Envelope
}
func Serve(conn net.Conn, hub *Hub) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 4096), chat.MaxBodyBytes*4)
conn.SetReadDeadline(time.Now().Add(readTimeout))
if !scanner.Scan() {
return
}
hello, err := chat.Decode(scanner.Bytes())
if err != nil || hello.Kind != chat.KindHello {
return
}
c := hub.Join(sanitizeUsername(hello.From))
c.conn = conn
c.send <- chat.Envelope{Kind: chat.KindWelcome, From: c.username}
writeDone := make(chan struct{})
go func() {
writePump(c)
close(writeDone)
}()
readPump(c, scanner, hub)
hub.Leave(c)
<-writeDone
}
func readPump(c *client, scanner *bufio.Scanner, hub *Hub) {
limit := newLimiter(5, 1)
for {
c.conn.SetReadDeadline(time.Now().Add(readTimeout))
if !scanner.Scan() {
return
}
env, err := chat.Decode(scanner.Bytes())
if err != nil || env.Kind != chat.KindMessage {
continue
}
if !limit.allow() {
continue
}
body := strings.TrimSpace(env.Body)
if body == "" || len(body) > chat.MaxBodyBytes {
continue
}
id := env.ID
if id == "" {
id = chat.NewID()
}
hub.Broadcast(chat.Envelope{
Kind: chat.KindMessage,
ID: id,
From: c.username,
Body: body,
SentAt: time.Now().UTC(),
})
}
}
func writePump(c *client) {
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
defer c.conn.Close()
for {
select {
case env, ok := <-c.send:
if !ok {
return
}
if err := writeEnvelope(c.conn, env); err != nil {
return
}
case <-ticker.C:
if err := writeEnvelope(c.conn, chat.Envelope{Kind: chat.KindPing}); err != nil {
return
}
}
}
}
func writeEnvelope(conn net.Conn, env chat.Envelope) error {
b, err := chat.Encode(env)
if err != nil {
return err
}
conn.SetWriteDeadline(time.Now().Add(writeTimeout))
_, err = conn.Write(b)
return err
}
func sanitizeUsername(raw string) string {
name := strings.TrimSpace(raw)
name = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, name)
if len(name) > 32 {
name = name[:32]
}
if name == "" {
name = "anon-" + chat.NewID()[:8]
}
return name
}
Slow down here, because this is the most delicate part of the whole project:
- A single
bufio.Scannerfor the entire connection’s lifetime. It’s created once inServe, reads the hello line, and that exact samescanner— not a new one — gets passed intoreadPump. If you created a second reader wrapping the samenet.Conn, you’d lose whatever bytes the first one already buffered internally. scanner.Buffer(..., chat.MaxBodyBytes*4)puts a hard ceiling in place. Without it, a malicious client could send a gigabyte-long “line” with no newline and blow up the server’s memory before your length validation ever gets a chance to reject it. With the limit set,Scan()simply fails and the connection closes.writePumpalways closesc.conn, no matter why it exited — a write error, or thesendchannel being closed by the Hub when it evicts a slow client. Closing the connection from there immediately unblocks theScan()thatreadPumphas pending on the other side.- The
if _, ok := h.clients[c]; okguard inHub.Leave(from the previous step) isn’t paranoia — it’s necessary. If the Hub already evicted a slow client fromdeliver, andServelater callshub.Leave(c)on its own way out, that check prevents a doublecloseon an already-closed channel, which would panic. - The heartbeat is purely activity-based. The server never needs to inspect the contents of a
pong— the mere fact thatScan()returns successfully (whatever arrived) resetsSetReadDeadlineon the next loop pass. A client that never writes anything, not even apong, gets disconnected after 90 seconds.
Notice something the server deliberately does not do: it never trusts any Kind a client sends except message. A client can’t forge a system announcement or inject a fake ping — any Kind other than message is simply ignored in readPump.
internal/server/hub_test.go
package server
import (
"testing"
"time"
"forum/internal/chat"
)
func TestHubDeduplicatesMessages(t *testing.T) {
hub := NewHub()
go hub.Run()
alice := hub.Join("alice")
defer hub.Leave(alice)
drain(t, alice.send)
msg := chat.Envelope{Kind: chat.KindMessage, ID: chat.NewID(), From: "bob", Body: "hi"}
hub.Broadcast(msg)
hub.Broadcast(msg)
first := recvMessage(t, alice.send)
if first.ID != msg.ID {
t.Fatalf("expected message %s, got %s", msg.ID, first.ID)
}
select {
case env := <-alice.send:
t.Fatalf("expected no duplicate, got %+v", env)
case <-time.After(200 * time.Millisecond):
}
}
func drain(t *testing.T, ch <-chan chat.Envelope) {
t.Helper()
select {
case <-ch:
case <-time.After(time.Second):
t.Fatal("timed out waiting for join system message")
}
}
func recvMessage(t *testing.T, ch <-chan chat.Envelope) chat.Envelope {
t.Helper()
select {
case env := <-ch:
return env
case <-time.After(time.Second):
t.Fatal("timed out waiting for message")
return chat.Envelope{}
}
}
This test isn’t decoration — it’s concrete proof that “no duplicates” is actually true. hub.Broadcast(msg) is called twice with the exact same ID, and the test fails if alice receives the message more than once.
Step 4: TLS Certificates — No InsecureSkipVerify
“Secure” means encryption in transit and real certificate verification — not skipping verification just because the certificate is self-signed. Generate a self-signed certificate and explicitly trust it, instead of disabling verification altogether.
mkdir -p certs
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -days 365 -nodes \
-keyout certs/server.key -out certs/server.crt \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
The client is going to load that exact server.crt as its one trusted CA (RootCAs), and require that ServerName match — exactly the same verification you’d get against a certificate issued by a public authority. InsecureSkipVerify: true exists as a flag for development, but use it and you’ve lost all protection against a machine-in-the-middle intercepting the connection. This project keeps it available behind an explicit flag — never as the default.
Step 5: forumd — The Server
cmd/forumd/main.go
package main
import (
"context"
"crypto/tls"
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
"forum/internal/server"
)
func main() {
addr := flag.String("addr", ":4000", "listen address")
certFile := flag.String("cert", "certs/server.crt", "TLS certificate path")
keyFile := flag.String("key", "certs/server.key", "TLS key path")
flag.Parse()
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
cert, err := tls.LoadX509KeyPair(*certFile, *keyFile)
if err != nil {
slog.Error("load TLS certificate", "error", err)
os.Exit(1)
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS13,
}
listener, err := tls.Listen("tcp", *addr, tlsConfig)
if err != nil {
slog.Error("listen", "error", err)
os.Exit(1)
}
defer listener.Close()
hub := server.NewHub()
go hub.Run()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
listener.Close()
}()
slog.Info("forum listening", "addr", *addr)
for {
conn, err := listener.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
slog.Warn("accept", "error", err)
continue
}
go server.Serve(conn, hub)
}
}
tls.Listen drops in for net.Listen without changing anything else about the flow — every connection that comes out of Accept() already arrives encrypted. On SIGTERM or Ctrl+C, closing the listener makes Accept() return an error immediately; since ctx.Err() is no longer nil at that point, the loop exits cleanly instead of logging a false error.
Start the server:
go run ./cmd/forumd
Step 6: forum — The CLI Client
The client has three responsibilities running in parallel: read from the socket and print, read from stdin and send, and reconnect only when the network actually fails. The key piece that keeps this from turning into goroutine chaos is that stdin is read exactly once, for the lifetime of the process — not once per connection attempt. That way, whatever you type during a network blip simply waits in a channel until reconnection completes.
internal/client/session.go
package client
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"math/rand/v2"
"net"
"os"
"strings"
"time"
"forum/internal/chat"
)
type Config struct {
Addr string
Username string
TLSConfig *tls.Config
}
func Run(ctx context.Context, cfg Config) error {
send := make(chan chat.Envelope, 64)
go func() {
_ = scanStdin(ctx, send)
}()
backoff := time.Second
for {
if ctx.Err() != nil {
return ctx.Err()
}
start := time.Now()
err := connectAndChat(ctx, cfg, send)
if ctx.Err() != nil {
return ctx.Err()
}
if time.Since(start) > 5*time.Second {
backoff = time.Second
}
fmt.Fprintf(os.Stderr, "\nconnection lost (%v), retrying in %s...\n", err, backoff)
select {
case <-time.After(backoff + jitter()):
case <-ctx.Done():
return ctx.Err()
}
backoff = min(backoff*2, 30*time.Second)
}
}
func jitter() time.Duration {
return time.Duration(rand.Int64N(int64(500 * time.Millisecond)))
}
func connectAndChat(ctx context.Context, cfg Config, send chan chat.Envelope) error {
dialer := &net.Dialer{Timeout: 10 * time.Second}
rawConn, err := dialer.DialContext(ctx, "tcp", cfg.Addr)
if err != nil {
return fmt.Errorf("dial: %w", err)
}
conn := tls.Client(rawConn, cfg.TLSConfig)
if err := conn.HandshakeContext(ctx); err != nil {
conn.Close()
return fmt.Errorf("tls handshake: %w", err)
}
defer conn.Close()
hello, err := chat.Encode(chat.Envelope{Kind: chat.KindHello, From: cfg.Username})
if err != nil {
return err
}
if _, err := conn.Write(hello); err != nil {
return fmt.Errorf("handshake write: %w", err)
}
connCtx, cancel := context.WithCancel(ctx)
defer cancel()
errs := make(chan error, 2)
go func() { errs <- readLoop(conn, send) }()
go func() { errs <- writeLoop(connCtx, conn, send) }()
err = <-errs
cancel()
return err
}
func readLoop(conn net.Conn, send chan<- chat.Envelope) error {
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 4096), chat.MaxBodyBytes*4)
for {
conn.SetReadDeadline(time.Now().Add(2 * time.Minute))
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return fmt.Errorf("read: %w", err)
}
return fmt.Errorf("read: connection closed")
}
env, err := chat.Decode(scanner.Bytes())
if err != nil {
continue
}
switch env.Kind {
case chat.KindWelcome:
fmt.Printf("\rconnected to the forum as %s\n> ", env.From)
case chat.KindPing:
select {
case send <- chat.Envelope{Kind: chat.KindPong}:
default:
}
case chat.KindMessage:
fmt.Printf("\r[%s] %s: %s\n> ", env.SentAt.Local().Format("15:04:05"), env.From, env.Body)
case chat.KindSystem:
fmt.Printf("\r*** %s\n> ", env.Body)
}
}
}
func writeLoop(ctx context.Context, conn net.Conn, send <-chan chat.Envelope) error {
for {
select {
case env := <-send:
b, err := chat.Encode(env)
if err != nil {
continue
}
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if _, err := conn.Write(b); err != nil {
return fmt.Errorf("write: %w", err)
}
case <-ctx.Done():
return ctx.Err()
}
}
}
func scanStdin(ctx context.Context, send chan<- chat.Envelope) error {
fmt.Print("> ")
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
if ctx.Err() != nil {
return ctx.Err()
}
text := strings.TrimSpace(scanner.Text())
if text == "" {
fmt.Print("> ")
continue
}
if len(text) > chat.MaxBodyBytes {
fmt.Printf("message too long (max %d bytes), not sent\n> ", chat.MaxBodyBytes)
continue
}
send <- chat.Envelope{Kind: chat.KindMessage, ID: chat.NewID(), Body: text}
fmt.Print("> ")
}
return fmt.Errorf("stdin closed")
}
What makes reconnection safe instead of a mess of dangling goroutines:
sendis created exactly once, inRun, and lives forever.scanStdinwrites to it; every connection attempt, no matter how many times it reconnects, reads from it through a freshwriteLoop. There are never two goroutines readingstdinat the same time, becausescanStdinonly ever launches once.connCtxcancels its sibling the moment one fails. IfreadLoopdies (the network dropped),connectAndChatgets that error, callscancel(), andwriteLoopexits through its<-ctx.Done()branch instead of sitting forever waiting on a channel nobody’s going to fill with any urgency.- Backoff only resets if the previous connection lasted more than five seconds. That way a real network outage doesn’t leave you retrying every 30 seconds forever once the network comes back — but a server that rejects the connection instantly does trigger the full backoff, so you don’t hammer it.
- Jitter avoids the thundering herd. If the server goes down with twenty clients connected, without jitter all twenty would retry at exactly the same instant, over and over. With
500msof randomness added to the backoff, they spread out.
There’s one small, honest race window: if you type a message at the exact instant the connection dies, that specific message can be lost instead of retried — the select inside writeLoop could, in theory, drain it from the channel right before ctx.Done() fires. Fixing that properly requires an explicit per-message acknowledgment (ACK) protocol, which is out of scope for this guide — and is, in fact, the exercise waiting for you at the end.
cmd/forum/main.go
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"flag"
"fmt"
"net"
"os"
"os/signal"
"os/user"
"syscall"
"forum/internal/client"
)
func main() {
addr := flag.String("addr", "localhost:4000", "server address")
username := flag.String("user", defaultUsername(), "your username in the forum")
caFile := flag.String("ca", "certs/server.crt", "trusted certificate (self-signed CA)")
insecure := flag.Bool("insecure-skip-verify", false, "skip TLS verification (development only)")
flag.Parse()
tlsConfig := &tls.Config{ServerName: hostOnly(*addr)}
if *insecure {
tlsConfig.InsecureSkipVerify = true
} else {
pem, err := os.ReadFile(*caFile)
if err != nil {
fmt.Fprintln(os.Stderr, "read CA file:", err)
os.Exit(1)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
fmt.Fprintln(os.Stderr, "invalid CA file")
os.Exit(1)
}
tlsConfig.RootCAs = pool
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
err := client.Run(ctx, client.Config{
Addr: *addr,
Username: *username,
TLSConfig: tlsConfig,
})
if err != nil && ctx.Err() == nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
}
}
func hostOnly(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}
func defaultUsername() string {
if u, err := user.Current(); err == nil && u.Username != "" {
return u.Username
}
return "anon"
}
defaultUsername uses os/user to guess your OS username — a small touch that saves you from typing -user=omar every time you test this.
Trying It Out
Terminal 1 — the server:
go run ./cmd/forumd
Terminal 2 — first client:
go run ./cmd/forum -user=alice
> connected to the forum as alice
> hello everyone
Terminal 3 — second client:
go run ./cmd/forum -user=bob
> connected to the forum as bob
> *** alice joined the forum
> [14:32:07] alice: hello everyone
> hey alice
Back in alice’s terminal, you’ll see bob’s message arrive in real time. Now test the resilience for real: kill the server with Ctrl+C in Terminal 1. Both clients print connection lost, retrying in 1s..., and the moment you bring go run ./cmd/forumd back up, they reconnect on their own — without you touching either client terminal.
Step 7: Docker
Since there are no external dependencies, there isn’t even a go.sum to copy.
Dockerfile
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/forumd ./cmd/forumd
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/forumd /forumd
COPY certs/server.crt certs/server.key /certs/
EXPOSE 4000
ENTRYPOINT ["/forumd", "-addr=:4000", "-cert=/certs/server.crt", "-key=/certs/server.key"]
docker-compose.yml
services:
forumd:
build: .
ports:
- "4000:4000"
restart: unless-stopped
No volumes. On purpose — there’s nothing to persist. If the container restarts, the forum starts from zero, which is exactly the behavior you asked for.
Makefile
certs:
mkdir -p certs
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -days 365 -nodes \
-keyout certs/server.key -out certs/server.crt \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
server:
go run ./cmd/forumd
client:
go run ./cmd/forum
test:
go test ./...
up:
docker compose up --build
down:
docker compose down
The forum client is an interactive terminal tool — you’d normally run it natively on your machine, not inside Docker. The server is the good candidate for containerizing; the client lives wherever the human typing lives.
go test ./...
docker compose up --build
Known Limits (And Exercises For You)
This guide is honest about where it deliberately stops:
- No exact-once delivery guarantee. There’s a tiny race window where a message typed right as the connection dies can be lost. Exercise: add a
Kind: "ack"the server sends back for every accepted message, and have the client retry if it doesn’t see the ACK in time — thanks to theIDyou already generate, that retry is safely duplicate-proof without touching anything else. - Rate limiting is silent. Go over 5 messages a second and the excess is dropped without a word. Exercise: have the server reply with a
system-kindEnvelopeexplaining why it was dropped. - It’s one forum, no rooms. Natural exercise: add a
Roomfield toEnvelope, and makeHub.deliveronly deliver to clients subscribed to that room — still no new mutex required. - No persistence, by design. If you ever wanted history, the right way to add it without breaking the architecture is one more subscriber of the Hub — a special read-only client that writes every
Envelopeto SQLite, exactly like in the notes API guide — without ever touching the live broadcast.
That last point is, maybe, the most important lesson in this whole project: the Hub doesn’t know or care who’s on the other end of c.send. It could be a TCP socket, a test, or a database writer. That’s the real payoff of keeping state in exactly one place, spoken to through channels.