Build a Tiny Chat Server in Go

After reading antirez’s elegant Smallchat program, I really appreciated it. It's a simple C program that shows how clean and minimal a chat server can be, and it stuck with me. That inspired me to write something similar in Go—keeping it small and easy to understand, but using Go’s built-in concurrency and networking features to make it even more approachable.
In this article, I'll walk you through the entire process step by step, sharing both the code and my thought process along the way.
By the end, we'll have a working chat server (just ~100 LOC) where you can use the nc
(Netcat) command to join from different terminal windows, and any message sent by one client will be broadcast to all connected clients.
What We're Building
The goal is to create a simple TCP-based chat server with the following features:
TCP Server: Listens on a port and accepts client connections.
Multi-Client Support: Handles multiple clients concurrently.
Broadcast Messaging: When one client sends a message, it appears for all connected clients.
Nicknames: Each client gets a random nickname when they join, and they can change it using the
/nick
command.
Step 1: Representing a Connected Client
We need a way to represent each user that connects to our server. For this, we'll define a Client
struct that contains:
The TCP connection itself.
A nickname for the user.
Each time a new connection is made, we'll create a Client
object. To give each client a unique identity, we'll generate a random 4-character nickname.
type Client struct {
conn net.Conn
nick string
}
func NewClient(conn net.Conn) *Client {
client := &Client{conn: conn}
client.nick = RandString(4)
return client
}
We'll use a helper function to generate the random nickname:
func RandString(n int) string {
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
s := make([]rune, n)
for i := range s {
s[i] = letters[rand.Intn(len(letters))]
}
return string(s)
}
How It Works
Client struct: Holds the connection and nickname.
NewClient: Instantiates a new client and assigns it a random nickname (e.g., "aB3k").
RandString: Generates a string by randomly selecting characters from a predefined set.
Step 2: Managing Multiple Clients
We need to keep track of all connected clients so that we can:
Add a client when they connect.
Remove a client when they disconnect.
Broadcast messages from one client to all others.
We'll create a ClientMgr
(Client Manager) struct that holds a map of clients. To allow safe concurrent access, we'll use a read-write mutex (sync.RWMutex
). Note: think about why we use sync.RWMutex
here?
type ClientMgr struct {
clientMap map[*Client]struct{}
sync.RWMutex
}
func NewClientMgr() *ClientMgr {
return &ClientMgr{clientMap: make(map[*Client]struct{})}
}
Methods to add, remove, and send messages:
func (c *ClientMgr) AddClient(client *Client) {
c.Lock()
defer c.Unlock()
c.clientMap[client] = struct{}{}
fmt.Println("Client added:", client.nick)
}
func (c *ClientMgr) RemoveClient(client *Client) {
c.Lock()
defer c.Unlock()
delete(c.clientMap, client)
fmt.Println("Client removed:", client.nick)
}
func (c *ClientMgr) SendMessage(sender *Client, message string) {
c.RLock()
defer c.RUnlock()
for client := range c.clientMap {
if client == sender {
continue
}
client.conn.Write([]byte(sender.nick + ": " + message))
}
}
How It Works
AddClient/RemoveClient: Manage the map of active clients using locks to prevent race conditions.
SendMessage: Loops through all clients (except the sender) to write the incoming message to each of their connections.
Step 3: Starting the Server
The server must:
Listen on a specific TCP port.
Accept incoming client connections.
Create a new
Client
for each connection.Spawn a separate goroutine to handle each client’s communication.
We'll use Go's net.Listen
to start the server and then accept connections in a loop. For every new connection, we'll create a client, add it to our manager, and launch a goroutine to process incoming messages.
The Code
func main() {
listenAddr := "127.0.0.1:12345"
listener, err := net.Listen("tcp", listenAddr)
if err != nil {
fmt.Println("Error listening:", err)
os.Exit(1)
}
defer listener.Close()
fmt.Println("Server is listening on", listenAddr)
for {
conn, err := listener.Accept()
if err != nil {
fmt.Println("Error accepting connection:", err)
continue
}
client := NewClient(conn)
clientMgr.AddClient(client)
go handleClient(client)
}
}
How It Works
net.Listen: Starts the TCP server.
Infinite loop: Continuously accepts new connections.
Goroutines: Each client is handled concurrently so multiple users can chat simultaneously.
Step 4: Handling Incoming Client Messages
Each connected client should be able to send messages to the server. The server must:
Read messages from the client.
Detect commands (e.g.,
/nick
to change the nickname).Broadcast non-command messages to all other clients.
We'll use a buffered reader (bufio.Reader
) to read input from the client line by line. A bufio.Reader
can reduce unnecessary read system calls to improve performance. Note if a message starts with a slash /
, we treat it as a command. Otherwise, we broadcast it as a message.
The Code
func handleClient(c *Client) {
defer c.conn.Close()
defer clientMgr.RemoveClient(c)
reader := bufio.NewReader(c.conn)
for {
message, err := reader.ReadString('\n')
if err != nil {
fmt.Println("Connection closed:", err)
break
}
if message[0] == '/' {
// Handle commands (e.g., changing nickname)
parts := strings.SplitN(message, " ", 2)
cmd := parts[0]
if cmd == "/nick" && len(parts) > 1 {
oldNick := c.nick
c.nick = strings.Trim(parts[1], "\n")
fmt.Println("Client renamed from", oldNick, "to", c.nick)
}
continue
}
fmt.Print("Received:", message)
clientMgr.SendMessage(c, message)
}
}
How It Works
Reading messages: Uses
bufio.Reader
to read each line.Command processing: Checks if the message starts with a slash (
/
) and handles/nick
to change the nickname.Broadcasting: Any non-command message is passed to the
SendMessage
method to be relayed to all other connected clients.
Step 5: Trying It Out!
Now that our code is ready, it’s time to test our chat server.
Run the Server
Open your terminal and run:
go run chat_server.go
You should see an output like:
Server is listening on 127.0.0.1:12345
Connect with Netcat
Open another terminal window and type:
nc 127.0.0.1 12345
Repeat this step in additional terminal windows to simulate multiple clients.
Chat Away
Now, type messages in one terminal. The messages will appear in the other connected clients. You can also change your nickname by typing:
/nick YourNewName
Your new nickname will be reflected in all future messages.
Demo
As you can see, when one user sends a message, the server broadcasts it to all other users.
Wrapping Up
In this project, we built a simple but effective chat server in Go that covers:
TCP Networking: Listening and accepting client connections.
Concurrency: Using goroutines to handle multiple clients at the same time.
Synchronization: Safely managing shared resources with mutexes.
Simple Command Parsing: Handling commands like
/nick
.
Check out the code on GitHub. Happy coding and happy chatting!
Subscribe to my newsletter
Read articles from David Zhang directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
