LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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.