// projects

net-cat

complete

Unix NetCat rebuilt from scratch in Go. TCP server-client architecture with support for multiple concurrent connections.

GoTCPNetworking
GitHub →

01 — Overview

Net Cat is a terminal-based group chat server built on raw TCP, written in Go from scratch. It replicates the core behaviour of the Unix nc utility — but purpose-built for multi-user real-time messaging rather than generic piping.

It accepts up to ten concurrent clients, replays full message history to late joiners, and handles graceful shutdown with client notification — all without any external dependencies.

02 — The problem

The goal was to build a working mental model of network programming: how does a process actually listen for connections, read from a socket, and write to it simultaneously without one blocking another? TCP and goroutines were the subject — not the chat feature.

Chat was a useful constraint. It forced real problems: what happens when two users send a message at the same time? What does a new user see when they join a conversation mid-way? What happens to the server when a client disconnects abruptly? These aren't hypothetical edge cases — they happen constantly.

03 — Technical decisions

The most consequential choices, and the reasoning behind them.

Channel + mutex — two tools, two jobs

A single Broadcast channel serialises the fan-out: all outgoing writes flow through one goroutine, so no two goroutines write to the same connection simultaneously. A sync.Mutex sits alongside it, protecting the shared Users map and HistoryMessages slice from concurrent reads and writes.

They solve different problems. The channel handles ordering of writes to clients; the mutex guards shared state that multiple goroutines touch directly. Trying to collapse both concerns into one mechanism — pure channels or pure locks — would either over-complicate the state access or reintroduce data races on the connection map.

One goroutine per client

Each accepted connection gets its own goroutine for reading. Go's scheduler handles concurrency cheaply. It maps naturally to "one user = one independent reader" and avoids event-loop complexity.

In-memory history slice

Message history is kept as a []string in the server struct. New clients receive it as a single joined write on connection. Simple, fast, and sufficient for a session-scoped chat.

05 — What I learned

Channels don't replace mutexes — they relocate contention. Using a channel to serialise writes avoided concurrent socket writes, but the broadcast goroutine still needed a mutex to protect the user map and history slice. The lesson: channels are great for passing ownership; mutexes are for protecting shared state you can't pass.

Terminal output is stateful — and that state is invisible. Writing to a termin1al isn't like writing to a file. The cursor position, the partially typed line, the scroll position all exist outside your program. ANSI escapes are the only interface.

Graceful shutdown is harder than startup. Stopping the server means draining the broadcast channel, closing all connections, and preventing new goroutines from writing to a closed channel — all in the right order.

What I'd do differently: separate the transport layer (raw TCP read/write) from the application logic (username validation, history, broadcast rules) more cleanly. Right now server.go mixes both. With clearer boundaries, testing individual pieces — especially validation and history replay — becomes straightforward without needing a live socket.

06 — Stack

Go 1.22 net.Listener goroutines channels sync.Mutex bufio ANSI escapes log os/signal no external deps