Skip to main content

Command Palette

Search for a command to run...

Building a Multi-Client Chat Server in Java

Published
5 min readView as Markdown
A

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:

  1. Connections are long-lived

  2. Multiple threads access shared data

  3. 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