LearnAI ToolsCareerPractice BuildsPlayContact
Go (Golang)Intermediate~2.5 hours

TCP Chat Server

Build a multi-client chat server that broadcasts messages to all connected users.

GoroutinesNetworkingChannels

Overview

A chat server has to do two things that pull in opposite directions at once: accept new TCP connections while simultaneously reading from every connection already open, and relay whatever any one client types out to every other client, all without ever letting two goroutines write to the same slice at the same time. Go's combination of lightweight goroutines and channels makes this almost boringly straightforward compared to the same problem in a language without built-in concurrency support — one goroutine per client connection, plus one dedicated broadcaster goroutine that owns the client list and is the only thing ever allowed to touch it.

By the end of this tutorial you will have a working `net.Listen`-based TCP server: each incoming connection gets its own goroutine reading lines from that client and forwarding them to a single shared `broadcast` channel, and one central goroutine reads from that channel and writes each message out to every other connected client's socket. You will see why routing all writes to the shared client list through a single goroutine — the "own it in one place" pattern — is often simpler and just as fast as protecting a shared slice with a mutex directly.

What You'll Build
  • A `Client` struct wrapping a `net.Conn` and the username chosen at connect time.
  • An accept loop using `net.Listen` and `Accept()` that spawns one goroutine per connection.
  • A per-client read loop using `bufio.Scanner` that forwards each line to a shared `broadcast` channel.
  • A central `broadcaster()` goroutine that owns the client list and fans every message out to everyone else.
  • Safe client registration and removal on connect/disconnect via dedicated `register`/`unregister` channels.
  • A `main()` that starts the listener and the broadcaster and loops forever accepting new clients.

Prerequisites

  • Goroutines and channels — the same worker-pool concepts from the Concurrent URL Checker project.
  • The `net` package — `net.Listen`, `Listener.Accept()`, and reading/writing a `net.Conn`.
  • `bufio.Scanner` for reading newline-delimited text from a connection.
  • Struct methods with pointer receivers — `func (c *Client) ...`.
  • The `select` statement for waiting on multiple channels at once (used briefly in the broadcaster).

Project Structure

The whole server lives in a single file, `main.go`. A `Client` struct pairs a `net.Conn` with a display name. Three unbuffered channels connect every goroutine to a single `broadcaster()` goroutine: `register` (a new client just connected), `unregister` (a client disconnected), and `broadcast` (a chat message needs to go out to everyone). `broadcaster()` is the only goroutine that ever reads or writes the `clients` map directly — every other goroutine only ever talks to it through those three channels, which is what makes the whole design safe without a single explicit `sync.Mutex` anywhere in the file.

Each connected client is handled by its own `handleClient()` goroutine, spawned from the accept loop in `main()`. That goroutine's only job is reading lines from its own socket and forwarding them (with the sender's name attached) onto the shared `broadcast` channel — it never writes to any other client's socket directly, which is what `broadcaster()` is for.

Step 1: Define the Client and Message Types

Every message that reaches `broadcaster()` needs to carry both the text and, implicitly, which `Client` sent it, so the message text is pre-formatted with the sender's name before it is ever put on the `broadcast` channel — that keeps `broadcaster()` itself simple: it never needs to know who sent a message, only where to send it.

package main
import "net"
// Client represents one connected chat participant: their raw TCP
// connection and the display name they chose when they connected.
type Client struct {
conn net.Conn
name string
}

Step 2: Build the Accept Loop

`net.Listen("tcp", addr)` opens a socket bound to `addr` and returns a `net.Listener`; calling `Accept()` on it blocks until a client connects, then returns a `net.Conn` for that one connection and immediately lets the next call to `Accept()` block again. Wrapping every accepted connection in `go handleClient(...)` is what lets the accept loop keep listening for new clients without ever waiting on an existing one to finish.

import (
"fmt"
"log"
"net"
)
// acceptLoop blocks forever, accepting new TCP connections on listener
// and spawning a dedicated goroutine to handle each one.
func acceptLoop(listener net.Listener, register, unregister chan *Client, broadcast chan string) {
for {
conn, err := listener.Accept() // Blocks until a new client connects
if err != nil {
log.Println("accept error:", err) // A single failed accept shouldn't crash the whole server
continue
}
go handleClient(conn, register, unregister, broadcast) // One goroutine per connection; the loop moves on immediately
}
}

Step 3: Handle One Client Connection

`handleClient()` first asks for a username with `fmt.Fprintln(conn, ...)` (writing straight to the socket) and reads the reply with a `bufio.Scanner`, then sends this new `Client` on the `register` channel so `broadcaster()` can add it to the shared list. `defer` schedules the `unregister` send and `conn.Close()` together, so however this function eventually returns — the client closes the connection, or `Scan()` hits an error — cleanup always happens.

import (
"bufio"
"fmt"
"net"
"strings"
)
// handleClient owns one client's connection for its entire lifetime: it
// prompts for a name, registers the client, then relays every line the
// client sends to the shared broadcast channel until the connection closes.
func handleClient(conn net.Conn, register, unregister chan *Client, broadcast chan string) {
fmt.Fprintln(conn, "Enter your name:") // Write directly to the socket; the client sees this as server output
scanner := bufio.NewScanner(conn)
if !scanner.Scan() { // Client disconnected before sending a name at all
conn.Close()
return
}
name := strings.TrimSpace(scanner.Text())
if name == "" {
name = "Anonymous"
}
client := &Client{conn: conn, name: name}
register <- client // Tell broadcaster() to add this client to the shared list
defer func() {
unregister <- client // Runs no matter how this function returns: disconnect, error, or scanner ending
conn.Close()
}()
broadcast <- fmt.Sprintf("* %s has joined the chat *", name)
for scanner.Scan() { // Blocks here until the client sends a line, disconnects, or the connection errors out
text := strings.TrimSpace(scanner.Text())
if text == "" {
continue // Ignore blank lines instead of broadcasting an empty message
}
broadcast <- fmt.Sprintf("%s: %s", name, text) // Prefix with the sender's name before it ever reaches broadcaster()
}
}

Step 4: Broadcast Messages to Every Client

`broadcaster()` is the one goroutine in the whole program that owns the `clients` map outright — nothing else ever reads or writes it. A `select` statement waits on all three channels at once and handles whichever one has a value ready first, which is what lets one goroutine safely serve `register`, `unregister`, and `broadcast` without any of them blocking the others for long.

// broadcaster owns the live client list and is the only goroutine allowed
// to read or write it, which is what keeps this design safe without a
// mutex: every other goroutine only ever talks to it through channels.
func broadcaster(register, unregister chan *Client, broadcast chan string) {
clients := make(map[*Client]bool) // Set of currently connected clients; the bool value is never actually used
for {
select { // Waits on whichever of these three channels has a value ready first
case client := <-register:
clients[client] = true
fmt.Println("registered:", client.name)
case client := <-unregister:
if _, ok := clients[client]; ok {
delete(clients, client)
fmt.Println("unregistered:", client.name)
}
case message := <-broadcast:
for client := range clients { // Fan the message out to every currently connected client
fmt.Fprintln(client.conn, message) // Writing to a closed/dead conn here just returns a harmless error
}
}
}
}

Step 5: Manage the Client List Safely

It is worth being explicit about why Step 4 needs no `sync.Mutex` at all: `register`, `unregister`, and `broadcast` are all unbuffered channels, and `broadcaster()`'s `select` loop processes exactly one event at a time, one at a time, forever. Every other goroutine in the program only ever interacts with the client list indirectly, by sending a value on one of those three channels — this is Go's "share memory by communicating" philosophy applied directly, rather than the more traditional "communicate by sharing memory (behind a lock)" a Java or C# chat server would typically use.

// No code in this step — it is a checkpoint. The three channels declared
// in Step 6's main() (register, unregister, broadcast) are the entire
// synchronization mechanism for the client list; nothing else is needed.

Step 6: Wire Up main()

`main()` creates the three shared channels, starts `broadcaster()` as its own long-lived goroutine, opens the TCP listener, and hands off to `acceptLoop()`, which then runs forever. Notice that `acceptLoop()` is called directly (not with `go`) as the very last line of `main()` — if it were started as a goroutine instead, `main()` would return immediately afterward and the whole program would exit before ever accepting a single client.

import (
"fmt"
"log"
"net"
)
const port = ":9000"
func main() {
register := make(chan *Client) // New client just connected and picked a name
unregister := make(chan *Client) // A client's connection ended
broadcast := make(chan string) // A line of chat text needs to go out to everyone
go broadcaster(register, unregister, broadcast) // Runs for the entire lifetime of the server
listener, err := net.Listen("tcp", port)
if err != nil {
log.Fatal("failed to start server:", err) // Can't recover from this; exit immediately
}
defer listener.Close()
fmt.Println("Chat server listening on port", port)
acceptLoop(listener, register, unregister, broadcast) // Blocks forever; must NOT be run with "go" or main() would exit
}

Complete Code

Here is the full server assembled in one file, ready to save as `main.go` and run with `go run main.go`. Connect with any TCP client, for example `nc localhost 9000` (or `telnet localhost 9000`) from multiple terminals at once.

package main
import (
"bufio"
"fmt"
"log"
"net"
"strings"
)
const port = ":9000"
type Client struct {
conn net.Conn
name string
}
func broadcaster(register, unregister chan *Client, broadcast chan string) {
clients := make(map[*Client]bool)
for {
select {
case client := <-register:
clients[client] = true
fmt.Println("registered:", client.name)
case client := <-unregister:
if _, ok := clients[client]; ok {
delete(clients, client)
fmt.Println("unregistered:", client.name)
}
case message := <-broadcast:
for client := range clients {
fmt.Fprintln(client.conn, message)
}
}
}
}
func handleClient(conn net.Conn, register, unregister chan *Client, broadcast chan string) {
fmt.Fprintln(conn, "Enter your name:")
scanner := bufio.NewScanner(conn)
if !scanner.Scan() {
conn.Close()
return
}
name := strings.TrimSpace(scanner.Text())
if name == "" {
name = "Anonymous"
}
client := &Client{conn: conn, name: name}
register <- client
defer func() {
unregister <- client
conn.Close()
}()
broadcast <- fmt.Sprintf("* %s has joined the chat *", name)
for scanner.Scan() {
text := strings.TrimSpace(scanner.Text())
if text == "" {
continue
}
broadcast <- fmt.Sprintf("%s: %s", name, text)
}
}
func acceptLoop(listener net.Listener, register, unregister chan *Client, broadcast chan string) {
for {
conn, err := listener.Accept()
if err != nil {
log.Println("accept error:", err)
continue
}
go handleClient(conn, register, unregister, broadcast)
}
}
func main() {
register := make(chan *Client)
unregister := make(chan *Client)
broadcast := make(chan string)
go broadcaster(register, unregister, broadcast)
listener, err := net.Listen("tcp", port)
if err != nil {
log.Fatal("failed to start server:", err)
}
defer listener.Close()
fmt.Println("Chat server listening on port", port)
acceptLoop(listener, register, unregister, broadcast)
}

Sample Run

Sample Run

Click Run to see what this code prints.

Extend This Project

  • Add private messaging with a `/msg <name> <text>` command parsed inside `handleClient()` before broadcasting.
  • Give `broadcaster()` a `whoisonline` channel so a client can type `/who` and get back a list of currently connected names.
  • Add graceful shutdown: listen for `SIGINT` with `os/signal`, close the listener, and notify every connected client before exiting.
  • Rate-limit each client with a `time.Ticker` so a single connection cannot flood the broadcast channel with messages.
  • Add chat rooms by giving each `Client` a `room` field and changing `broadcaster()` to fan messages out only to clients in the same room.

Summary

You built a working multi-client chat server using goroutines and channels as Go's answer to a problem most languages solve with threads and locks: one goroutine per client connection, a single `broadcaster()` goroutine that is the sole owner of the shared client list, and three channels — `register`, `unregister`, `broadcast` — as the only path any other goroutine has to affect that list. That "own the shared state in one goroutine, talk to it only through channels" pattern is Go's idiomatic alternative to `sync.Mutex`, and it is one you will see again in almost any non-trivial concurrent Go server.