Tokio Chat Server
A real-time multi-client chat server built in Rust with Tokio, TCP networking, PostgreSQL persistence and asynchronous task-based concurrency.
- Role -
- Independent Project
- Date -
- Completed
- Stack -
- Rust, Tokio, TCP, Async Programming, Concurrency, PostgreSQL, SQLx
I built this project to get more comfortable with asynchronous networking and concurrent backend systems in Rust.
The result is a stateful TCP-based chat server that can handle multiple clients concurrently. Clients can register, log in, join chat groups and exchange messages in real time. Messages and group membership are persisted in PostgreSQL, while the active group and session state is kept in memory.
The main thing I wanted to understand was how the different pieces of a real-time backend fit together: TCP connections, asynchronous tasks, shared state, message fan-out, database transactions and connection lifecycles.
Architecture
The system has a CLI client, an asynchronous TCP server and a PostgreSQL database.
CLI Client
|
| JSON over TCP
v
Tokio TCP Server
|
+------------+-------------+
| | |
v v v
Sessions Groups Client Tasks
| | |
| v |
| broadcast::Sender |
| | |
| v |
| Group Receivers |
| |
+------------+-------------+
|
v
PostgreSQL
SQLx
Each TCP connection is handled in its own Tokio task. Shared sessions and group channels are stored in concurrent maps, while chat history, users and membership are persisted in PostgreSQL.
TCP Server
The server listens on:
127.0.0.1:8081
When a client connects, the server splits the TCP stream into a reader and writer and parses the initial command as JSON.
The command layer currently supports:
Register
Login
Join
Logout
After a successful Join, the same TCP connection stays open and becomes the client’s real-time chat connection.
This creates two different phases for the connection:
Command request
|
v
Authentication / group join
|
v
Persistent chat stream
I liked this part of the project because it made the difference between a normal request/response interaction and a long-lived network connection very concrete.
Async Connection Handling
Each accepted client connection is handled with tokio::spawn:
tokio::spawn(async move {
if let Err(e) = handle_connection(socket, server_clone, addr).await {
// ...
}
});
This lets the server handle multiple clients concurrently without creating a dedicated OS thread for each connection.
Once a client joins a group, its task waits on two things using tokio::select!:
+----------------------+
| Client TCP input |
| OR |
| Group broadcast |
+----------------------+
|
v
handle event
One branch reads messages arriving from the client.
The other receives messages or membership events from the group’s broadcast channel.
This means the same task can both accept new messages from its client and forward messages arriving from other clients.
Group Broadcasts
Each chat group has its own tokio::sync::broadcast channel.
The server keeps these channels in a concurrent DashMap:
group_id
|
v
broadcast::Sender
|
+---- client A receiver
|
+---- client B receiver
|
+---- client C receiver
When a client sends a message, the server first persists it to PostgreSQL and then broadcasts an Event::Message to the group’s channel.
Clients other than the sender receive the event through their own receiver.
The same mechanism is used for:
Join
Leave
Message
The sender also includes the client’s socket address with the event so that a connection can ignore its own broadcast.
Why broadcast?
I used tokio::sync::broadcast because the same message needs to reach multiple clients in a group.
Instead of the server explicitly iterating through every connected client, each client subscribes to the group’s channel and receives the events independently.
The channel is bounded, with a capacity of 100 messages.
That also introduces an important backpressure tradeoff: a receiver that falls too far behind can receive a Lagged error and miss messages that have already fallen out of the channel buffer.
For this project, that behavior is acceptable because the database remains the persistent source for chat history.
Session Management
The server supports registration and login rather than allowing clients to immediately enter a chat group.
During registration, passwords are hashed with bcrypt before being stored in PostgreSQL.
After successful login, the server generates a UUID session ID and stores the session in an in-memory DashMap.
The client then stores the session ID locally and uses it when joining a group.
The flow is:
Register
|
v
bcrypt password hash
|
v
PostgreSQL
Login
|
v
verify password
|
v
generate UUID session
|
v
store session in memory
|
v
SESSION <id>
Join
|
v
validate session
|
v
join group
Sessions expire after 30 minutes.
The server also runs a background cleanup task every 10 minutes to remove expired sessions from memory.
Database and Persistence
PostgreSQL is used for persistent state and is accessed through SQLx.
The schema contains tables for:
- users
- group members
- group requests
- group chats
The group_requests table records join and leave operations, while group_members tracks current membership.
Chat messages are stored in group_chats with the group ID, username, message and timestamp.
This gives the system a useful separation:
In-memory
-----------
active sessions
active groups
broadcast channels
PostgreSQL
-----------
users
memberships
join/leave history
chat history
Database Transactions
Database writes for important state changes are performed through SQLx transactions.
For example, when a client joins a group, the server:
- validates the session
- checks that the user exists
- records the join event
- inserts the group membership
- commits the transaction
- continues into the chat loop
Messages follow a similar pattern. The message is inserted into PostgreSQL and the transaction is committed before the server broadcasts the message to the other clients.
That ordering means a successfully broadcast chat message has already been persisted.
Chat History
When a client joins a group, the server retrieves the most recent 20 messages from PostgreSQL.
The query orders them newest-first, then reverses the result before sending it to the client so that the history appears in chronological order:
Database
newest -> oldest
reverse
Client
oldest -> newest
After the history is sent, the same TCP connection remains open for real-time messages.
Client
The project also contains a CLI client built with clap.
The main commands are:
cargo run --bin client -- register <username> <password>
cargo run --bin client -- login <username> <password>
cargo run --bin client -- join <group_id>
cargo run --bin client -- logout
The client stores the session returned by the server in a local session file and uses it for subsequent group joins.
While connected to a chat group, the client uses tokio::select! to wait for either:
- input from the terminal
- messages arriving from the server
This makes the CLI capable of sending messages and receiving other users’ messages at the same time.
Protocol
The application uses a lightweight JSON-over-TCP protocol for its command messages.
For example, a login command is represented as a serialized enum containing:
{
"type": "Login",
"username": "...",
"password": "..."
}
The initial command is newline-delimited so that the server can read and deserialize one command at a time.
Once the client joins a group, the connection switches to newline-delimited chat messages rather than repeatedly opening new connections.
I intentionally kept the protocol small because the main goal of the project was understanding the networking and concurrency model rather than designing a full application protocol.
Shared State
The server uses DashMap for concurrent access to:
sessions
groups
Sessions map session IDs to usernames and creation times.
Groups map group IDs to their broadcast::Sender.
This lets independently running connection tasks access shared state without putting one large mutex around the entire server.
The group channel itself handles the synchronization needed for message fan-out.
Connection Lifecycle
A connected client roughly follows this lifecycle:
TCP connect
|
v
parse command
|
+---- Register ----> PostgreSQL
|
+---- Login -------> create session
|
+---- Join --------> validate session
|
v
subscribe
|
v
load history
|
v
chat loop
/ \
receive broadcast
input events
\ /
|
v
TCP disconnect
|
v
leave event
When a client disconnects, the server records the leave event, removes the membership from the database and broadcasts a leave notification to the remaining clients.
Error Handling and Observability
The server defines application-level errors for:
- database failures
- I/O failures
- password hashing errors
- authentication failures
- invalid input
- internal errors
Errors are logged using the tracing crate and surfaced to the client where appropriate.
For example, invalid sessions and invalid group IDs are returned as explicit ERROR: responses rather than being treated as generic internal failures.
The server also logs conditions such as failed network writes and lagging broadcast receivers.
Testing
The project includes unit and asynchronous integration-style tests.
The tests cover:
- successful registration
- duplicate registration
- successful login
- incorrect passwords
- logout
- invalid sessions
- invalid JSON commands
- database operations
- join and leave behavior
- message insertion and history
- invalid group IDs
The server tests create an ephemeral TCP listener and interact with the actual connection handling code, which makes the tests more representative than only testing individual helper functions.
Design Tradeoffs
This project intentionally keeps the architecture simple, which also makes some limitations visible.
In-memory broadcast
Group message delivery uses Tokio’s in-memory broadcast channel.
That works well for a single server process, but it does not provide cross-process or cross-machine message delivery.
A distributed version would need something such as Redis or NATS for shared pub/sub.
Single-node state
Active sessions and group channels live in process memory.
That means a second server instance would not automatically know about the state held by the first.
A production horizontally scaled version would need a shared session/state layer.
JSON over TCP
JSON keeps the protocol easy to inspect while developing, but it is not as compact or strongly typed as a binary protocol such as Protobuf.
Database writes on every message
Messages are persisted before being broadcast. This gives the database a durable record, but it also means database throughput can eventually become a bottleneck for very high message rates.
What I Learned
This project was mainly about understanding asynchronous backend systems by actually building one.
I got much more comfortable with:
- Tokio async tasks
- TCP streams
tokio::select!- channels and broadcast subscriptions
- concurrent shared state
- PostgreSQL transactions
- connection lifecycles
- authentication and sessions
- event-driven message handling
One thing that became much clearer was the relationship between the TCP connection and the broadcast channel.
The TCP stream is the actual connection between a client and the server. The broadcast channel is an internal server-side mechanism that lets multiple connection tasks receive the same group event without the server having to manage a separate direct send operation for every client.
That distinction is useful when thinking about how the same architecture could eventually be adapted to WebSockets.
Current State
The current implementation is a single-node real-time chat server using TCP, Tokio and PostgreSQL.
It is intentionally not presented as a production-ready distributed chat system. The project was built to understand asynchronous networking, concurrency and stateful backend design, while making the important scalability tradeoffs visible in the implementation itself.
The next areas I would explore are a browser-facing WebSocket interface, a structured protocol such as Protobuf, distributed pub/sub and a shared state layer for horizontal scaling.