Build a Chat Server in Go With Goroutines and Channels
Build a TCP chat server in Go with a hub goroutine that owns the clients, a reader and writer per connection, slow client handling, commands and tests.

A chat server is the textbook goroutine example for a good reason: it has many independent connections that each wait most of the time, a piece of shared state that everyone touches (who is connected), and one operation that crosses all the connections (broadcast). The first version is fifty lines. The version that survives a client that stops reading, a disconnect at the wrong moment and a shutdown takes more thought, and every piece of that thought applies to real servers: websockets, game rooms, notification fan-out.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
We build a line-based TCP chat with nicknames and commands, and aim for the second kind of version. It applies channels, the actor style, pub/sub with channels and graceful shutdown, and belongs to the concurrency patterns cluster. Everything ran on Go 1.25.6 with the standard library only.
The architecture
Three kinds of goroutine, and a rule about who owns what.
- The hub is a single goroutine. It owns the set of connected clients and the table of nicknames. Nothing else touches them, so there is no lock. Everything that changes or reads that state is an event sent to the hub on a channel.
- Each connection has a reader goroutine. It reads lines from the socket, turns each into an event, and sends it to the hub. It never touches other clients.
- Each connection has a writer goroutine. It receives strings from the client's own outbound channel and writes them to the socket. It is the only writer of that socket.
The hub talks to a client only by putting strings on that client's outbound channel. The channel is bounded, which is the key decision: the hub must never block on a client. If a client's queue is full, the hub does not wait for it. That is what stops a single client that has stopped reading from freezing the entire chat.
Clients and events
package main
import (
"net"
"sync"
)
const outboundQueue = 64 // how many lines may wait for one slow client
type client struct {
conn net.Conn
name string
out chan string // owned by the hub: only the hub sends on it and closes it
}
type eventKind int
const (
evJoin eventKind = iota
evLeave
evSay
evNick
evWho
)
type event struct {
kind eventKind
from *client
text string
}
// Server is a line-based chat server.
type Server struct {
ln net.Listener
events chan event
quit chan struct{}
wg sync.WaitGroup // every goroutine the server starts
once sync.Once
mu sync.Mutex // guards only the counter below, which tests read from outside the hub
connected int
dropped int // clients disconnected for being too slow
}
// NewServer starts listening on addr and serving in the background.
func NewServer(addr string) (*Server, error) {
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
s := &Server{ln: ln, events: make(chan event), quit: make(chan struct{})}
s.wg.Go(s.hub)
s.wg.Go(s.accept)
return s, nil
}
func (s *Server) Addr() string { return s.ln.Addr().String() }
func (s *Server) Stats() (connected, dropped int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.connected, s.dropped
}The mu is not a violation of the no-lock design: it protects two counters that the tests read from the outside, and the hub updates. The chat state (clients, names) is the hub's alone.
Accepting and serving a connection
accept loops on Accept and, for each connection, starts the pair of goroutines. The reader announces the join to the hub, translates input into events, and signals the leave when the connection ends, whatever the reason:
package main
import (
"bufio"
"fmt"
"strings"
)
func (s *Server) accept() {
for {
conn, err := s.ln.Accept()
if err != nil {
return // the listener was closed: shutting down
}
c := &client{conn: conn, out: make(chan string, outboundQueue)}
s.wg.Go(func() { s.write(c) })
s.wg.Go(func() { s.read(c) })
}
}
// send delivers an event to the hub, or gives up if the server is shutting down.
func (s *Server) send(e event) bool {
select {
case s.events <- e:
return true
case <-s.quit:
return false
}
}
func (s *Server) read(c *client) {
defer c.conn.Close()
if !s.send(event{kind: evJoin, from: c}) {
return
}
defer s.send(event{kind: evLeave, from: c}) // tell the hub, whatever ended the loop
sc := bufio.NewScanner(c.conn)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
switch {
case line == "":
continue
case line == "/quit":
return
case line == "/who":
s.send(event{kind: evWho, from: c})
case strings.HasPrefix(line, "/nick "):
s.send(event{kind: evNick, from: c, text: strings.TrimSpace(line[len("/nick "):])})
default:
if !s.send(event{kind: evSay, from: c, text: line}) {
return
}
}
}
}
// write is the only goroutine that writes to the socket. It ends when the hub closes c.out.
func (s *Server) write(c *client) {
defer c.conn.Close() // unblocks the reader if the writer fails first
for line := range c.out {
if _, err := fmt.Fprintln(c.conn, line); err != nil {
// Keep draining so the hub never blocks on a dead client; the hub will drop it.
for range c.out {
}
return
}
}
}The two defers in read are ordered on purpose. The leave event is sent before the connection closes (defers run last-in, first-out), though either order would work here. What matters is that the leave event is sent however the loop ends: a /quit, a network error, an end of file, or a server shutdown. The hub relies on that: it learns about every departure from the reader, and it is the only place that removes a client and closes its outbound channel.
The writer has one subtle job. When a write fails, the client is broken, but the hub may still try to send to its channel for a moment. If the writer returned, the channel would fill and the hub's non-blocking send would take the "queue full" path, which is correct but noisy. Draining the channel until it is closed keeps the bookkeeping simple. Closing the connection on the way out is what unblocks the reader if it is stuck in Scan.
The hub
The hub is a loop with one select. Because nothing else reads or writes the clients and names maps, they are plain maps:
package main
import (
"fmt"
"sort"
"strings"
)
func (s *Server) hub() {
clients := map[*client]bool{}
names := map[string]*client{}
guests := 0
// deliver queues a line for one client without ever blocking the hub.
deliver := func(c *client, line string) {
select {
case c.out <- line:
default: // the queue is full: this client is too slow, so drop it
if clients[c] {
s.drop(c, clients, names)
}
}
}
broadcast := func(from *client, line string) {
for c := range clients {
if c != from {
deliver(c, line)
}
}
}
for {
select {
case e := <-s.events:
switch e.kind {
case evJoin:
guests++
e.from.name = fmt.Sprintf("guest-%d", guests)
clients[e.from] = true
names[e.from.name] = e.from
s.count(+1)
deliver(e.from, "welcome, you are "+e.from.name+" (commands: /nick NAME, /who, /quit)")
broadcast(e.from, "* "+e.from.name+" joined")
case evLeave:
if clients[e.from] {
delete(clients, e.from)
delete(names, e.from.name)
close(e.from.out) // the one place a client's channel is closed
s.count(-1)
broadcast(nil, "* "+e.from.name+" left")
}
case evSay:
if clients[e.from] {
broadcast(e.from, e.from.name+": "+e.text)
}
case evNick:
switch {
case !clients[e.from]:
case e.text == "" || strings.ContainsAny(e.text, " :"):
deliver(e.from, "! names cannot be empty or contain spaces or colons")
case names[e.text] != nil:
deliver(e.from, "! the name "+e.text+" is taken")
default:
old := e.from.name
delete(names, old)
e.from.name = e.text
names[e.text] = e.from
deliver(e.from, "you are now "+e.text)
broadcast(e.from, "* "+old+" is now "+e.text)
}
case evWho:
if clients[e.from] {
list := make([]string, 0, len(names))
for n := range names {
list = append(list, n)
}
sort.Strings(list)
deliver(e.from, "online: "+strings.Join(list, ", "))
}
}
case <-s.quit:
for c := range clients {
close(c.out)
}
return
}
}
}
// drop removes a client that cannot keep up. It runs in the hub goroutine.
func (s *Server) drop(c *client, clients map[*client]bool, names map[string]*client) {
delete(clients, c)
delete(names, c.name)
close(c.out)
c.conn.Close() // the writer may be stuck in a blocked write; closing the socket frees it
s.count(-1)
s.mu.Lock()
s.dropped++
s.mu.Unlock()
}
func (s *Server) count(delta int) {
s.mu.Lock()
s.connected += delta
s.mu.Unlock()
}
// Close stops accepting, tells the hub to finish, and waits for every goroutine.
func (s *Server) Close() {
s.once.Do(func() {
close(s.quit)
s.ln.Close()
})
s.wg.Wait()
}Read the hub as the specification of the protocol. A join assigns a guest name and announces it. A leave removes the client and closes its channel, and it is guarded by if clients[e.from], because a client the hub already dropped for slowness will still send a leave event when its reader notices the closed socket; the guard makes that second removal a no-op. broadcast skips the sender where appropriate and calls deliver, whose default case is the load-shedding decision: a full queue means the client is dropped now rather than waited for.
Closing a channel from the hub only is the rule that makes the whole design race-free (closing channels). The hub is the only sender on c.out, so it can close it without risking a send on a closed channel. The writer is the only receiver, and it exits when the close arrives.
The dropped-client path closes the connection as well. A client that has stopped reading has a writer blocked inside a socket write; closing the channel alone would not wake it, but closing the connection makes the write fail, the writer drains and exits, and the reader sees an error and sends its (now ignored) leave event.
A scripted session
The demo is a test client library of about twenty lines, and then a scenario with three polite clients and one slow one. Each client reads in its own goroutine into a slice, so the scenario can assert on what each one received:
package main
import (
"bufio"
"fmt"
"net"
"strings"
"sync"
"time"
)
type testClient struct {
conn net.Conn
mu sync.Mutex
lines []string
}
func dial(addr string) *testClient {
conn, err := net.Dial("tcp", addr)
if err != nil {
panic(err)
}
tc := &testClient{conn: conn}
go func() {
sc := bufio.NewScanner(conn)
for sc.Scan() {
tc.mu.Lock()
tc.lines = append(tc.lines, sc.Text())
tc.mu.Unlock()
}
}()
return tc
}
func (tc *testClient) send(line string) { fmt.Fprintln(tc.conn, line) }
// wait blocks until a received line contains substr, or fails after two seconds.
func (tc *testClient) wait(substr string) {
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
tc.mu.Lock()
for _, l := range tc.lines {
if strings.Contains(l, substr) {
tc.mu.Unlock()
return
}
}
tc.mu.Unlock()
time.Sleep(time.Millisecond)
}
panic("never received: " + substr)
}
func (tc *testClient) transcript() []string {
tc.mu.Lock()
defer tc.mu.Unlock()
return append([]string(nil), tc.lines...)
}
func main() {
srv, err := NewServer("127.0.0.1:0")
if err != nil {
panic(err)
}
defer srv.Close()
alice := dial(srv.Addr())
alice.wait("welcome")
alice.send("/nick alice")
alice.wait("you are now alice")
bob := dial(srv.Addr())
bob.wait("welcome")
bob.send("/nick bob")
bob.wait("you are now bob")
alice.wait("bob")
alice.send("hello everyone")
bob.wait("alice: hello everyone")
bob.send("/who")
bob.wait("online:")
fmt.Println("--- alice saw ---")
for _, l := range alice.transcript() {
fmt.Println(l)
}
fmt.Println("--- bob saw ---")
for _, l := range bob.transcript() {
fmt.Println(l)
}
// A client that connects and never reads.
sleeper, err := net.Dial("tcp", srv.Addr())
if err != nil {
panic(err)
}
defer sleeper.Close()
fmt.Fprintln(sleeper, "/nick sleeper") // and then it never calls Read
big := strings.Repeat("x", 16<<10)
for range 600 {
alice.send(big)
time.Sleep(200 * time.Microsecond) // a fast typist, not an instant burst
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if _, dropped := srv.Stats(); dropped > 0 {
break
}
time.Sleep(5 * time.Millisecond)
}
connected, dropped := srv.Stats()
fmt.Printf("after flooding: %d connected, %d dropped for being too slow\n", connected, dropped)
bob.send("still here?")
alice.wait("bob: still here?")
fmt.Println("alice still receives bob's messages after the slow client was dropped")
}--- alice saw ---
welcome, you are guest-1 (commands: /nick NAME, /who, /quit)
you are now alice
* guest-2 joined
* guest-2 is now bob
--- bob saw ---
welcome, you are guest-2 (commands: /nick NAME, /who, /quit)
you are now bob
alice: hello everyone
online: alice, bob
after flooding: 2 connected, 1 dropped for being too slow
alice still receives bob's messages after the slow client was droppedThe scenario shows the protocol: names, the join announcement, a broadcast that reaches everyone else but not the sender, and /who. The second half is the one that matters. The sleeper connection says its name and then never reads from its socket, so nobody drains the data the server sends it. Alice then sends 600 lines of 16 KB. Each line is broadcast to bob and to the sleeper; bob keeps up, the sleeper's socket buffers fill, its writer blocks, its outbound queue of 64 fills, and the next broadcast takes the default branch in deliver. The hub drops the sleeper, and the final line shows that the chat carried on: the connected count falls and bob and alice still talk. Without the bounded queue and the non-blocking send, the hub would block on the sleeper's channel, and everyone else would freeze until the sleeper's TCP connection timed out.
Sizing the queue
The queue length is the one number in this design that has to be chosen, and the first version of my demo got it wrong in an instructive way. With a queue of 16 lines and alice sending 600 lines of 16 KB with no pause, the server reported two dropped clients, not one: the sleeper, as intended, and also bob, who was reading as fast as he could. The hub is much faster than a TCP connection. It turned alice's burst into broadcast events within a few milliseconds, and bob's writer, which had to push 16 KB per line through a socket and bob's reader, could not drain 16 slots that quickly. A healthy client lost a race against a burst.
The queue is a buffer for bursts, and it has to be sized for the largest burst a healthy client should survive. A queue of 64 with a slightly paced sender (a 200 microsecond gap, which is closer to a person typing quickly than to a loop) kept bob connected while the sleeper still overflowed. The general rules:
- Size for bursts, not for the average. If the biggest legitimate burst is 100 messages, a queue of 16 will drop healthy clients and a queue of 1,000 will hide a stuck one for a long time.
- Bound the memory. Every client holds
queue size x message sizebytes in the worst case. Ten thousand clients with 64 slots of 16 KB each is 10 GB of potential backlog, so cap message length and queue length together. - Decide the policy and say it. Dropping the client is one policy. Dropping the oldest queued message and keeping the connection is another, right for state feeds such as a score ticker where only the latest matters. Dropping the new message and carrying on suits best-effort notifications. A chat that must show every line should drop the client, since silently skipping lines is worse than reconnecting.
- Make the drop visible. The server counts drops. Export that counter, and also tell the client why it was disconnected where you can, so that a reconnecting client resumes sensibly.
Limits on input
A server on a network must assume that clients are careless or hostile. This one has two gaps that a few lines close. bufio.Scanner limits a line to 64 KB by default and stops with an error beyond that, which here ends the connection; that is a reasonable default and you can set the limit explicitly with sc.Buffer(make([]byte, 4096), 8192). And nothing stops a client from connecting and sending nothing, holding a goroutine pair and a file descriptor forever. A read deadline renewed on each line does:
for {
c.conn.SetReadDeadline(time.Now().Add(5 * time.Minute)) // idle clients are removed
if !sc.Scan() {
break
}
// ... handle the line ...
}The deadline is absolute, so it has to be reset before every read. A client that is merely quiet for five minutes is disconnected, and a client that chats stays. A slow sender, the opposite problem of the slow reader this article is about, is handled by the same mechanism (timeouts on every blocking call).
What happens at shutdown
The shutdown path is easy to get wrong and worth walking through. Close closes quit and the listener. The listener close makes Accept fail, which ends accept. The hub sees quit, closes every client's outbound channel and returns. Each writer, seeing its channel closed, exits and closes its connection. Each reader, now reading from a closed connection, gets an error from Scan, returns, and its deferred leave event is discarded because send selects on quit. The WaitGroup reaches zero once the last goroutine has returned, and Close returns. No goroutine is told about the shutdown directly except the hub; the rest follow from the connections closing, which is the cleanest way to propagate a stop through a pipeline of goroutines that block on I/O. If a goroutine did not exit, the test above would hang on Close and say so.
Tests
The lines above are a demonstration; the properties belong in tests that fail loudly. These start a fresh server per test and use the same small client helper:
package main
import (
"strings"
"testing"
"time"
)
func TestBroadcastReachesOthersNotSender(t *testing.T) {
srv, _ := NewServer("127.0.0.1:0")
defer srv.Close()
a, b := dial(srv.Addr()), dial(srv.Addr())
a.wait("welcome")
b.wait("welcome")
a.wait("joined") // a hears that b joined
a.send("hi")
b.wait("guest-1: hi")
time.Sleep(20 * time.Millisecond)
for _, l := range a.transcript() {
if strings.Contains(l, "guest-1: hi") {
t.Fatal("the sender received its own message")
}
}
}
func TestNicknamesMustBeUnique(t *testing.T) {
srv, _ := NewServer("127.0.0.1:0")
defer srv.Close()
a, b := dial(srv.Addr()), dial(srv.Addr())
a.wait("welcome")
b.wait("welcome")
a.send("/nick sam")
a.wait("you are now sam")
b.send("/nick sam")
b.wait("taken")
}
func TestLeaveIsAnnounced(t *testing.T) {
srv, _ := NewServer("127.0.0.1:0")
defer srv.Close()
a, b := dial(srv.Addr()), dial(srv.Addr())
a.wait("welcome")
b.wait("welcome")
b.send("/quit")
a.wait("left")
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if n, _ := srv.Stats(); n == 1 {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("the leaving client was not removed")
}
func TestCloseStopsEverything(t *testing.T) {
srv, _ := NewServer("127.0.0.1:0")
a := dial(srv.Addr())
a.wait("welcome")
done := make(chan struct{})
go func() { srv.Close(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Close did not return: a goroutine is stuck")
}
}=== RUN TestBroadcastReachesOthersNotSender
--- PASS: TestBroadcastReachesOthersNotSender (0.03s)
=== RUN TestNicknamesMustBeUnique
--- PASS: TestNicknamesMustBeUnique (0.00s)
=== RUN TestLeaveIsAnnounced
--- PASS: TestLeaveIsAnnounced (0.00s)
=== RUN TestCloseStopsEverything
--- PASS: TestCloseStopsEverything (0.00s)
PASS
ok example.test/lab 1.281sTestCloseStopsEverything is the one that earns its keep: Close returns only when the WaitGroup reaches zero, so a goroutine that ignores shutdown hangs the test with a clear message instead of leaking quietly in production (starting and stopping goroutines). All four run under -race, which also validates the claim that the hub's maps need no lock.
What a production version adds
Authentication and limits. Nobody should be able to join anonymously on the open internet, and every connection needs a read deadline and a maximum line length (bufio.Scanner has Buffer for that) so one client cannot send an endless line or hold a socket open with no data (timeouts on every blocking call). Rate limit each client's messages as well (rate limiting).
Rooms. Add a map from room name to a set of clients in the hub and let the evSay handler broadcast to one room. Because the hub is a single goroutine, the data structure changes are trivial; the work is in the commands.
Many hubs. One hub goroutine handles tens of thousands of messages per second, which is far more than most chat applications need, and it is also the limit. When it is, shard by room: one hub per room, with a registry that routes clients. The pattern is the one from the actor model.
WebSockets. Browsers do not speak raw TCP. The same architecture works over WebSockets, with the reader and writer goroutines wrapping a WebSocket connection instead of a socket; libraries such as coder/websocket provide one. Everything in the hub stays unchanged, which is the point of keeping it ignorant of the transport.
Presence and history. Persisting messages turns this into a different system. If you need replay for late joiners, a bounded ring buffer in the hub is the simplest start, and a durable log is the next step (a persistent job queue has the log pieces).
The standard library reference for what the server rests on is the net package documentation, the Go tour has its own chat-like exercises for practice, and the classic chat server appears in Donovan and Kernighan's book The Go Programming Language, which I'd recommend if you want a second take on the same design.


