← Back to work
/06·Apr 2025 – May 2025·Distributed Systems·4 min read

Distributed Network Chat App

Multi-client chat over TCP/IP with broadcast, unicast, and reliable buffered delivery.

  • C++
  • TCP/IP
  • POSIX Sockets
  • select()

A multi-client chat application implemented from scratch in C++ over raw POSIX sockets. The server multiplexes many concurrent client connections on a single thread using `select()`; clients can send unicast messages to a specific peer or broadcast to everyone connected.

The interesting parts of the project weren't the happy-path message delivery — they were the failure modes. What happens when a client disconnects mid-message? When the buffer fills up? When two messages interleave on the wire? The project forced me to think about TCP not as a stream of bytes but as a stream of *delimited frames* with explicit lifecycles.

Distributed Network Chat App

Goals

Build a chat application from scratch that:

  1. Supports many concurrent clients on a single server.
  2. Allows both unicast (client → specific client) and broadcast (client → everyone) messages.
  3. Delivers messages reliably even when clients are slow or temporarily backed up.
  4. Doesn't spawn a thread per client — uses I/O multiplexing instead.

No high-level networking libraries — only POSIX sockets and select().

Server architecture

The server runs a single select() loop. The fd_set tracks the listening socket plus every connected client. When select() returns, the server walks the ready descriptors:

  • Listening socket ready → accept a new client, add it to the connection table and the fd_set.
  • Client socket ready for read → drain available bytes into that client's input buffer, parse out any complete message frames, dispatch them.
  • Client socket ready for write → flush as much of that client's output queue as possible.

Each connected client owns:

  • An input buffer (partial message reassembly)
  • An output queue (messages waiting to be delivered)
  • A user-id / nickname

Messages are length-prefixed: a 4-byte big-endian length, then a JSON payload. This means the parser never has to guess where a message ends.

Reliability

TCP gives us in-order, reliable byte delivery — but it doesn't give us message framing, and it doesn't tell us when the receiver has actually processed the message. The buffered output queue handles the "slow consumer" case: if a client can't keep up, messages stack up in their queue rather than blocking the whole server. There's a configurable cap; if a client exceeds it, they get disconnected.

For dispatching:

  • Unicast lookups go through a hash table keyed by user-id.
  • Broadcast walks the connection table and pushes the message into every queue.

Either way, the actual write happens on the next select() write-ready notification, so the dispatch path is non-blocking.

Client

The client is simpler — it has stdin and the server socket, multiplexed with the same select() pattern. Input from stdin is parsed for commands (/msg <user> <text>, /all <text>, /quit), framed, and sent to the server.

What I learned

  • select() is enough. I started this expecting to need epoll or threads. With careful per-client buffering, a single select() loop handled all the concurrency the project needed and the code stayed readable.
  • Framing is the silent killer in network code. I spent more time on "did I get a complete message yet" than on any actual feature. Length-prefixed framing is boring and correct; everything else (delimiters, fixed-size messages, etc.) has edge cases.
  • Backpressure has to be in the design from day one. The slow-consumer case is where chat servers fall over in production, and bolting it on later is much harder than designing the queue with a cap from the start.

Stack

C++17, POSIX sockets, select(), single-threaded server. No external dependencies.

Next case study

Grojha — Hyperlocal Delivery, From Zero

Continue