DEV Community

Ryan Kelly
Ryan Kelly

Posted on

I Built a TCP Chat Server in Go Before I Understood Concurrency — Here's What Taught Me Goroutines, Channels, and Mutexes.

How building a Net-Cat clone became my crash course in Go concurrency.


Where It Started

When I first received the Net-Cat project, I thought it sounded pretty straightforward: build a TCP chat server, allow multiple clients to connect, let users choose usernames, broadcast messages, store chat history, and display join and leave notifications.

Simple enough, right? Not quite.

At the time, I knew some Go. I could write functions, work with structs, create packages, and build basic applications. What I didn't understand was concurrency.

I had heard people talk about it constantly — "Go is great because of concurrency," "use goroutines," "channels are amazing," "watch out for race conditions." I had heard all of those phrases. The problem was that if someone had stopped me and asked:

  • "What exactly is a goroutine?"
  • "Why would you use a channel?"
  • "What problem does a mutex solve?" I wouldn't have had a good answer. Building this chat server changed that.

This wasn't just a networking project. It became the project that finally made Go's concurrency model click for me.


The First Question I Had to Answer

Before thinking about goroutines, channels, or mutexes, I had to answer a simpler question: how should the application be structured?

After several redesigns, I landed on this:

.
├── main.go
├── server/
│   ├── server.go
│   ├── client.go
│   ├── room.go
│   ├── hub.go
│   ├── message.go
│   └── format.go
└── utils/
    └── ascii.go
Enter fullscreen mode Exit fullscreen mode

Looking at this now, it seems obvious. It definitely wasn't obvious while building it. The architecture only emerged after I started running into problems — and those problems are what taught me concurrency.


Concurrency Was Just a Buzzword Until This Project

Before this project, concurrency felt like one of those concepts everyone talks about but nobody explains in a way that sticks.

Then I started thinking about my server. If one client connects and starts chatting, what happens when another client connects? And another? And another? Surely the server can't just stop talking to one client every time a new one arrives.

That was the first time I encountered a real concurrency problem: multiple things needed to happen at the same time. That's where goroutines entered the picture.


What Is a Goroutine?

The simplest explanation I can give: a goroutine is a lightweight task that runs independently.

Without goroutines, a program typically runs one instruction after another:

Do A → Do B → Do C → Done
Enter fullscreen mode Exit fullscreen mode

But a chat server doesn't work like that. A chat server has many users connected simultaneously, each able to send messages whenever they want. If the server handled one client at a time, everybody else would be stuck waiting.

Instead:

go handleClient(conn)
Enter fullscreen mode Exit fullscreen mode

That single keyword — go — creates a new goroutine. Now each client gets its own independent execution flow:

Server
 ├── Client 1
 ├── Client 2
 ├── Client 3
 └── Client 4
Enter fullscreen mode Exit fullscreen mode

The moment I understood this, something clicked. The server wasn't one program anymore. It was lots of little workers running concurrently.


The Moment Goroutines Became Real

One of the biggest realizations came when implementing clients. Every client ended up needing two goroutines — one for reading messages, one for writing messages.

At first I thought: "Why two?" Then I realized both operations block. Reading blocks while waiting for user input. Writing blocks while waiting for outgoing messages. Those are completely independent activities.

go client.Read()
go client.Write()
Enter fullscreen mode Exit fullscreen mode

Now both can happen simultaneously. The client can receive messages while also sending them. That sounds obvious now. It wasn't obvious to me then.


What Is a Channel?

The best explanation I found: a channel is a pipe/pathway

One goroutine puts data in. Another goroutine takes data out.

messages := make(chan string)

// Send
messages <- "Hello"

// Receive
msg := <-messages
Enter fullscreen mode Exit fullscreen mode

The sender doesn't need to know who's receiving. The receiver doesn't need to know who's sending. The channel sits between them — like a mailbox:

Sender → Channel → Receiver
Enter fullscreen mode Exit fullscreen mode

How Channels Powered My Chat Rooms

Once I understood channels, the room architecture started making sense. A room wasn't just storing data — it was receiving events.

type Room struct {
    broadcast chan Message
    join      chan *Client
    leave     chan *Client
}
Enter fullscreen mode Exit fullscreen mode

Suddenly everything became event-driven:

room.join <- client      // Client joins
room.leave <- client     // Client leaves
room.broadcast <- msg    // New message arrives
Enter fullscreen mode Exit fullscreen mode

The room simply listens for events:

func (r *Room) Run() {
    for {
        select {
        case client := <-r.join:
            // handle join

        case client := <-r.leave:
            // handle leave

        case msg := <-r.broadcast:
            // handle message
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Before this project, select looked like weird Go syntax. After building the room system, it finally made sense. The room wasn't constantly checking for work — it was sleeping until something happened, waking up to handle it, then waiting again.

That was probably my first real "aha" moment with channels.


The Bug That Taught Me About Blocking

Then I hit a very important bug. My first broadcast implementation looked like this:

for client := range r.Clients {
    client.Send <- msg
}
Enter fullscreen mode Exit fullscreen mode

Looks fine, and it worked — until I started thinking about slow clients. What if somebody stops reading messages? Eventually their channel fills up. Then this line blocks. And once it blocks, the room blocks, the broadcasts block, and the entire chat freezes.

One slow client could affect everyone.

The solution:

for client := range r.Clients {
    select {
    case client.Send <- msg:
    default:
        close(client.Send)
        delete(r.Clients, client)
    }
}
Enter fullscreen mode Exit fullscreen mode

Now one problematic client can't bring down the entire room. This taught me an important lesson: concurrent systems must be designed around the possibility that things will fail.


Then I Met My First Race Condition

At this point I thought channels solved everything. They didn't.

The Hub stored rooms:

type Hub struct {
    rooms map[string]*Room
}
Enter fullscreen mode Exit fullscreen mode

Imagine: Client A creates a room, Client B reads a room, and Client C deletes a room — all simultaneously. Multiple goroutines touching the same data. This is called a race condition, and that's where mutexes entered my life.


What Is a Mutex?

The simplest explanation: a mutex is a lock.

Think of a bathroom key. If one person has it, everyone else waits. When they're done, they return it, and someone else can enter.

mu.Lock()
rooms["general"] = room
mu.Unlock()
Enter fullscreen mode Exit fullscreen mode

While the lock is held, nobody else can modify the shared data. This prevents corruption and inconsistent state.


How Mutexes Saved My Hub

The Hub became the single source of truth for rooms:

type Hub struct {
    rooms map[string]*Room
    mu    sync.RWMutex
}
Enter fullscreen mode Exit fullscreen mode

Whenever rooms were created or modified:

h.mu.Lock()
defer h.mu.Unlock()
Enter fullscreen mode Exit fullscreen mode

Whenever rooms were simply being read:

h.mu.RLock()
defer h.mu.RUnlock()
Enter fullscreen mode Exit fullscreen mode

Before this project, mutexes felt mysterious. After this project, they felt practical. They solve a very specific problem: protecting shared data.


The Mental Model That Finally Clicked

This is the explanation I wish someone had given me at the beginning:

Need multiple things happening at once?  →  Use Goroutines
Need goroutines to communicate?          →  Use Channels
Need to protect shared data?             →  Use Mutexes
Enter fullscreen mode Exit fullscreen mode

For a long time I treated these as separate, unrelated concepts. They're actually different tools solving different parts of the same problem.


The Hub: My Biggest Architectural Lesson

One thing I learned that had nothing to do with syntax was ownership. Initially the server managed rooms, and that became messy very quickly. Eventually I introduced a Hub, which became solely responsible for:

  • Creating rooms
  • Finding rooms
  • Moving clients between rooms
  • Cleaning up rooms Nothing else could do those things. Everything went through the Hub.

The lesson was simple: when many parts of a system need access to something, create one source of truth. It makes concurrent systems much easier to reason about.


The Import Cycle That Taught Me About Package Design

At one point I accidentally created this:

server → utils
utils  → server
Enter fullscreen mode Exit fullscreen mode

Go immediately refused to compile:

import cycle not allowed
Enter fullscreen mode Exit fullscreen mode

At first this was frustrating. Then I realized Go was protecting me. The formatter belonged next to the Message type, not in utils. Moving it into the server package solved everything.

Sometimes compiler errors are actually architecture lessons.


Testing Changed How I Thought About Code

The race detector was one of the most useful tools I discovered:

go test -race ./...
Enter fullscreen mode Exit fullscreen mode

It caught problems I didn't even know existed. Running with -race has become automatic whenever I'm working on concurrent code. If you're learning Go concurrency, use it early — it will save you hours.


Concurrency Cheat Sheet

Tool Purpose Mental model Example
Goroutine Run work concurrently A lightweight worker go handleClient(conn)
Channel Allow goroutines to communicate A pipe or mailbox messages <- msg
Mutex Protect shared data A lock on a door mu.Lock(); defer mu.Unlock()

What I Know Now That I Didn't Know Then

When I started this project, concurrency was just a word I heard experienced developers use. I didn't know what goroutines actually were. I didn't understand channels. I had never used a mutex.

By the end, those concepts weren't just definitions anymore — they were solutions to problems I had personally encountered:

  • Goroutines helped me handle multiple clients simultaneously.
  • Channels helped components communicate safely.
  • Mutexes protected shared state from corruption. Most importantly, I learned that concurrency isn't really about goroutines or channels. It's about coordination — deciding who owns data, how information flows, and what happens when something goes wrong.

Building this chat server didn't just teach me how to write concurrent Go code. It taught me why those concurrency tools exist in the first place. And that's a lesson I don't think I would have learned from a tutorial alone.


Built during my Go learning journey at Zone01 Kisumu, Kenya. If you're learning Go and concepts like goroutines, channels, and mutexes still feel abstract — build something that forces multiple things to happen at the same time. For me, this chat server was the project that finally made everything click.

Top comments (0)