021 Project 21: WebSocket Chat
021 Build a WebSocket Chat Server
A minimal real-time chat backend with a broadcast hub. Clients connect to /ws, send text frames, and receive every message fan-out to all peers.
clients <-> hub (mutex + map) <-> broadcast WriteMessage
Problem statement
- Upgrade HTTP → WebSocket on
/ws - Track connected clients
- Broadcast each inbound text message to all clients
- Remove clients on read error/close
- Lab-friendly:
CheckOriginallows all (document prod hardening)
Acceptance criteria
- Two clients can exchange messages
- Disconnect does not crash the server
- Mutex protects the client map (race-clean under
-race) - Builds with gorilla/websocket or nhooyr/coder websocket
- Clear run instructions
Setup
mkdir gochat && cd gochat
go mod init example.com/gochat
go get github.com/gorilla/websocket@latest
# go 1.27
go mod tidyFull main.go
package main
import (
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, // lab only
}
type hub struct {
mu sync.Mutex
clients map[*websocket.Conn]struct{}
}
func (h *hub) add(c *websocket.Conn) {
h.mu.Lock()
defer h.mu.Unlock()
h.clients[c] = struct{}{}
}
func (h *hub) del(c *websocket.Conn) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.clients, c)
_ = c.Close()
}
func (h *hub) broadcast(msg []byte) {
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.clients {
_ = c.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := c.WriteMessage(websocket.TextMessage, msg); err != nil {
delete(h.clients, c)
_ = c.Close()
}
}
}
func main() {
h := &hub{clients: map[*websocket.Conn]struct{}{}}
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
h.add(c)
defer h.del(c)
for {
_, msg, err := c.ReadMessage()
if err != nil {
return
}
h.broadcast(msg)
}
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(indexHTML))
})
log.Println("chat on http://localhost:8090/ ws://localhost:8090/ws")
log.Fatal(http.ListenAndServe(":8090", nil))
}
const indexHTML = `<!doctype html>
<title>gochat</title>
<pre id="log"></pre>
<input id="m" placeholder="message"/><button onclick="send()">send</button>
<script>
const log = (t) => { logEl.textContent += t + "\n"; };
const logEl = document.getElementById("log");
const ws = new WebSocket("ws://" + location.host + "/ws");
ws.onmessage = (e) => log("< " + e.data);
ws.onopen = () => log("connected");
function send() {
const v = document.getElementById("m").value;
ws.send(v);
log("> " + v);
}
</script>`Run and verification
go run .
# open two browser tabs to http://localhost:8090/
# send messages; both tabs should show traffic
go test -race ./... # if you extract hub testsManual wscat-style:
# if you have websocat or similar
# websocat ws://localhost:8090/wsTests
package main
import (
"testing"
)
func TestHubAddDel(t *testing.T) {
h := &hub{clients: map[*websocket.Conn]struct{}{}}
// without real conn, just ensure map ops are safe with nil key edge
// prefer integration test with httptest.Server for deeper coverage
if len(h.clients) != 0 {
t.Fatal()
}
}Better: use httptest.NewServer + gorilla dialer (stretch in repo).
Step-by-step build path
- Upgrade handler.
- Hub with mutexed set of conns.
- Read loop → broadcast.
- Remove on error; write deadlines.
- Tiny HTML client for demo.
Stretch goals
- Per-client send goroutine + outbound queue (avoid slow-client blocking hub).
- Nicknames and join/leave system messages.
- Rooms via path
/ws/{room}. - Origin allowlist for production.
- Ping/pong keepalive.
Pitfalls
| Pitfall | Fix |
|---|---|
| Concurrent map without mutex | data race |
| Broadcast write blocks hub | per-conn writer queues |
| CheckOrigin true in prod | CSRF-ish WS risks |
| No write deadline | hung peers stall all |
Learning goals
- WebSocket upgrade and framing
- Fan-out hub patterns
- Synchronization around connection sets