A multi-client chat room in about 80 lines. One goroutine per connection, one channel to broadcast.
A TCP server that anyone can connect to with telnet or nc. Everything one client types is broadcast to every other connected client. Join and leave events are announced.
# Terminal A (server)
$ go run main.go
chat server listening on :8080
# Terminal B (client)
$ telnet localhost 8080
welcome — what's your name?
> alice
* alice joined
# Terminal C (client)
$ telnet localhost 8080
welcome — what's your name?
> bob
* bob joined
alice: hi everyone
No external libraries. Just net and goroutines.
Three moving parts:
broadcaster goroutine — the single source of truth for who's connected. It listens on channels for “new client”, “client left”, and “message arrived”, and fans messages out to everyone.handleConnection goroutine per client — reads lines from the socket and sends them to the broadcaster.clientWriter goroutine per client — receives messages from the broadcaster and writes them to that client's socket.The broadcaster owns the map of clients, so there are no mutexes — only one goroutine ever touches that data. This is the classic Go pattern: don't communicate by sharing memory; share memory by communicating.
package main
import (
"bufio"
"fmt"
"log"
"net"
)
type client chan<- string // outgoing messages to one user
var (
entering = make(chan client)
leaving = make(chan client)
messages = make(chan string)
)
func broadcaster() {
clients := make(map[client]bool)
for {
select {
case msg := <-messages:
for cli := range clients {
cli <- msg
}
case cli := <-entering:
clients[cli] = true
case cli := <-leaving:
delete(clients, cli)
close(cli)
}
}
}
func handleConn(conn net.Conn) {
ch := make(chan string) // outgoing for this client
go clientWriter(conn, ch)
fmt.Fprintln(conn, "welcome — what's your name?")
scan := bufio.NewScanner(conn)
if !scan.Scan() {
conn.Close()
return
}
who := scan.Text()
ch <- "you are " + who
messages <- "* " + who + " joined"
entering <- ch
for scan.Scan() {
messages <- who + ": " + scan.Text()
}
leaving <- ch
messages <- "* " + who + " left"
conn.Close()
}
func clientWriter(conn net.Conn, ch <-chan string) {
for msg := range ch {
fmt.Fprintln(conn, msg)
}
}
func main() {
listener, err := net.Listen("tcp", ":8080")
if err != nil {
log.Fatal(err)
}
log.Println("chat server listening on :8080")
go broadcaster()
for {
conn, err := listener.Accept()
if err != nil {
log.Print(err)
continue
}
go handleConn(conn)
}
}
Trace one message through the system:
main's Accept() returns a new conn, and go handleConn(conn) spins up a goroutine just for you.handleConn, the first thing it does is start clientWriter as another goroutine. That writer's only job is to drain your personal ch channel and write each message to your socket.entering <- ch tells the broadcaster: “add this client's channel to the map.”scan.Scan() reads a line. messages <- who + ": " + scan.Text() hands it to the broadcaster.clientWriter goroutine wakes up, receives the message, and writes it to that user's socket.clients map is the broadcaster. Everyone else communicates through channels.$ mkdir chat && cd chat
$ go mod init chat
# paste the code into main.go
$ go run main.go
chat server listening on :8080
# in two or three other terminals:
$ telnet localhost 8080
$ nc localhost 8080 # if telnet isn't installed
Type a name when prompted, then start typing messages. Open more terminals and watch the messages fan out in real time.
/msg alice hello and only send to that user.conn.SetReadDeadline(...) after each scan; disconnect users who go quiet for 10 minutes./join general or /join dev. The broadcaster keeps one client-map per room.chat.log as it fans out.net.Listen for net/http with the gorilla/websocket package and you have a browser-based chat with the same broadcaster intact.