Building a Multi-Client Chat Server in Java
Associate System Engineer | Currently learning Java & building fun side projects | Blogging my dev journey to revise, reflect, and grow 👨💻🚀
Blocking I/O, Concurrency, and the Real Cost of Multithreading
In the earlier parts of this series, we built Java servers from scratch. We began with a single-threaded server to understand sockets and streams, then introduced multithreading, and finally stabilized execution using a thread pool.
At that point, the server could handle multiple clients concurrently — but only for short-lived interactions. A client connected, exchanged a message, and disconnected.
A chat server changes the problem entirely.
Clients remain connected for long periods of time. Messages arrive continuously. Every message must be delivered to multiple clients. Suddenly, concurrency is no longer just about “handling more users” — it becomes about shared state, coordination, and correctness.
This blog walks through a complete multi-client chat server implementation and explains why each design decision exists, including the doubts that naturally arise while building it.
How a Chat Server Changes the Problem
Earlier servers followed a request-response lifecycle. Each client interaction was short and isolated. That model hides many concurrency issues.
A chat server introduces three new realities:
Connections are long-lived
Multiple threads access shared data
Correctness matters more than raw concurrency
At this stage, simply adding threads is not enough. Concurrency must be controlled.
Starting Point: The Server Backbone
The server begins by binding to a port and waiting for connections. Instead of handling clients directly, it uses a fixed-size thread pool.
ExecutorService pool = Executors.newFixedThreadPool(10);
A Common Doubt
Why not create a new thread per client?
Because chat connections are long-lived. Each thread spends most of its lifetime blocked on input. Creating an unbounded number of threads would eventually exhaust memory and CPU due to context switching.
A fixed thread pool enforces limits. It prevents the server from collapsing under load. This choice is not about performance — it is about stability.
Accepting Client Connections
while (true) {
Socket acceptedConnection = serverSocket.accept();
logger.info("Server Socket is connected to: "
+ acceptedConnection.getRemoteSocketAddress());
pool.submit(() -> handler(acceptedConnection));
}
Each accepted socket is handed off to a worker thread. That thread now owns the client connection for its entire lifetime.
Tracking Connected Clients
To broadcast messages, the server must know who is connected. Each client is represented by its outgoing stream and username.
public static final Map<PrintWriter, String> clients =
new ConcurrentHashMap<>();
A Common Doubt
Why ConcurrentHashMap instead of HashMap?
Because:
multiple threads add clients
multiple threads remove clients
multiple threads iterate during broadcast
A normal HashMap would fail under concurrent access. ConcurrentHashMap allows safe reads, writes, and iteration without explicit synchronization. This single choice removes an entire class of concurrency bugs.
Handling a Client Connection
The core of the server lives inside the handler() method.
public static void handler(Socket socket) {
PrintWriter toClient = null;
try {
toClient = new PrintWriter(
socket.getOutputStream(), true);
BufferedReader fromClient =
new BufferedReader(
new InputStreamReader(
socket.getInputStream()));
toClient.println("Enter username: ");
String username = fromClient.readLine().trim();
clients.put(toClient, username);
broadcast(username + " has joined the chat");
At this point, the client is officially registered. The server announces their arrival to everyone else.
Reading Messages (Blocking by Design)
String message;
while ((message = fromClient.readLine()) != null) {
String timestamp =
LocalTime.now().format(timeFormat);
broadcast("[" + timestamp + "] "
+ username + ": " + message);
}
A Common Doubt
Isn’t this blocking the thread?
Yes — intentionally.
readLine() blocks until the client sends data. While blocked, the thread consumes no CPU. For I/O-bound systems like chat servers, this model is simple and efficient.
Blocking here is not a problem. Uncontrolled thread creation is.
Broadcasting Messages
public static void broadcast(String message) {
for (PrintWriter writer : clients.keySet()) {
writer.println(message);
}
}
A Common Doubt
Why is there no synchronized block here?
Because ConcurrentHashMap guarantees safe iteration even while other threads modify the map. Using the right data structure eliminates the need for explicit locking.
This is a powerful lesson: good design reduces complexity more effectively than defensive code.
Handling Client Disconnects Correctly
public static void removeClient(
PrintWriter writer, Socket socket) {
String username = clients.remove(writer);
if (username != null) {
broadcast(username + " left the chat");
}
socket.close();
}
A Common Doubt
Why must the socket be closed explicitly?
Thread pools manage threads — not network resources. Sockets are OS-level resources. If they are not closed, the server will eventually run out of file descriptors and crash.
Closing sockets is mandatory in long-running systems.
Client-Side Concurrency
The client must do two things at the same time:
listen for server messages
read user input
Both operations block.
Thread thread = new Thread(() -> {
try {
String mssg;
while ((mssg = fromServer.readLine()) != null) {
System.out.println(">>> " + mssg);
}
} catch (Exception ex) {
System.out.println("Connection closed");
}
});
thread.start();
A Common Doubt
Why can’t one thread handle both?
Because blocking on keyboard input prevents listening to the server, and blocking on the socket prevents reading user input. Splitting responsibilities into two threads is the only correct solution.
This mirrors how real chat clients work internally.
Graceful Client Exit
if ("exit".equalsIgnoreCase(input)) {
System.out.println("Disconnecting...");
toSocket.println("has left the chat.");
socket.close();
break;
}
Closing the socket causes the server’s readLine() to return null, which naturally triggers cleanup and broadcast on the server side. This is a clean, predictable shutdown mechanism.
Why Multithreading Is Not Always Better
By the time this chat server works reliably, a key realization becomes unavoidable.
Threads solve blocking — but introduce cost.
Each thread consumes memory. Context switching adds overhead. Shared state requires coordination. As concurrency increases, these costs can outweigh the benefits.
Thread pools exist not to make systems faster, but to keep them under control.
More threads ≠ more performance.
What This Project Teaches
Building this server manually reveals lessons that frameworks often hide:
Blocking I/O is simple and effective
Threads must be bounded
Shared state is the hardest problem
Correct data structures matter more than locks
Thread pools enforce stability, not speed
These principles apply to every backend system — not just chat servers.
Final Thoughts
Frameworks abstract sockets, threads, and concurrency, but they do not remove them. When something goes wrong in production, this foundational understanding is what allows you to reason clearly instead of guessing.
If you understand why each line in this chat server exists, you understand the core of backend server design.
GitHub URL: https://github.com/arshghaiwat/ClientServer.git