Skip to main content

Command Palette

Search for a command to run...

Building a Java Server from Scratch

Published
6 min readView as Markdown
A

Associate System Engineer | Currently learning Java & building fun side projects | Blogging my dev journey to revise, reflect, and grow 👨‍💻🚀

Understanding Sockets, Streams, Threads, and Thread Pools (The Right Way)

When we use frameworks like Spring Boot, Tomcat, or Netty, the complexity of networking is hidden behind annotations and abstractions. But under the hood, all of them rely on the same fundamental concepts: sockets, streams, threads, and concurrency control.

In this blog, we will build a Java server step by step—from the most basic single-threaded version to a scalable thread-pool-based server. The goal is not just to “make it work”, but to understand every single line of code and why it exists.

If you understand this blog completely, you will understand the foundation of backend servers.


How a Server Actually Works (Before Writing Any Code)

At the lowest level, a server does five things:

  1. It binds itself to a port

  2. It waits for a client to connect

  3. It creates a connection object for that client

  4. It exchanges data using streams

  5. It closes the connection

Everything else—multi-threading, thread pools, logging—is built on top of this.

Let’s start with the simplest possible implementation.


Part 1: Single-Threaded Server (The Foundation)

This server can handle one client at a time. It is not scalable, but it is perfect for understanding how sockets and streams work.

Complete Server Code

package singleThreadedServer;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.logging.Logger;

public class Server {

    public static final Logger logger =
            Logger.getLogger(Server.class.getName());

    public void run() throws Exception {

        int port = 8010;

        ServerSocket serverSocket = new ServerSocket(port);
        System.out.println("Server is listening on port: " + port);

        while (true) {

            Socket acceptedConnection = serverSocket.accept();

            logger.info("Server connected to client: "
                    + acceptedConnection.getRemoteSocketAddress());

            PrintWriter toClient =
                    new PrintWriter(
                            acceptedConnection.getOutputStream(), true);

            BufferedReader fromClient =
                    new BufferedReader(
                            new InputStreamReader(
                                    acceptedConnection.getInputStream()));

            toClient.println("Hello from Server");

            String clientMessage = fromClient.readLine();
            logger.info("Client said: " + clientMessage);

            acceptedConnection.close();
        }
    }

    public static void main(String[] args) throws Exception {
        new Server().run();
    }
}

Understanding ServerSocket and accept()

ServerSocket serverSocket = new ServerSocket(port);

This line binds your application to a port number. From this point onward, the operating system knows that your program is responsible for handling incoming connections on that port.

The heart of the server is this line:

Socket acceptedConnection = serverSocket.accept();

This call blocks the current thread. The server stops executing here and waits until a client connects. When a client connects, Java creates a new Socket object and returns it.

This is a crucial concept:

  • ServerSocket is long-lived and shared

  • Socket is created per client

Every client connection gets its own socket.


What a Socket Represents

A Socket represents a single TCP connection between the client and the server.

It contains:

  • the client’s IP address and port

  • an input stream (client → server)

  • an output stream (server → client)

This is why sockets must never be reused or shared between clients.


Why We Use BufferedReader and PrintWriter

Sockets communicate using bytes, but applications usually communicate using text. To convert bytes into readable text and vice versa, we wrap streams.

Writing Data to the Client

PrintWriter toClient =
        new PrintWriter(socket.getOutputStream(), true);

Here’s what happens:

  • getOutputStream() gives a byte stream

  • PrintWriter lets us write text

  • true enables auto-flush, ensuring data is sent immediately

When we call:

toClient.println("Hello from Server");

the message is sent across the network to the client.


Reading Data from the Client

BufferedReader fromClient =
        new BufferedReader(
                new InputStreamReader(
                        socket.getInputStream()));

This chain exists for a reason:

  • InputStream reads raw bytes

  • InputStreamReader converts bytes to characters

  • BufferedReader allows efficient line-by-line reading

Calling:

String message = fromClient.readLine();

blocks until the client sends data.


The Role of Logger

Instead of System.out.println, we use:

public static final Logger logger =
        Logger.getLogger(Server.class.getName());

A logger allows us to:

  • control log levels

  • include timestamps

  • safely log in multi-threaded environments

When we write:

logger.info("Client said: " + message);

we gain visibility into server behavior without disrupting execution.


Why This Server Is Not Enough

This server handles only one client at a time. If one client is slow or blocks, every other client must wait. This is unacceptable in real systems.

We fix this by introducing threads.


Part 2: Multi-Threaded Server (One Thread per Client)

In a multi-threaded server, each client connection is handled independently in its own thread.

Complete Server Code

package multiThreadedServer;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.function.Consumer;
import java.util.logging.Logger;

public class Server {

    public static final Logger logger =
            Logger.getLogger(Server.class.getName());

    public Consumer<Socket> getConsumer() {
        return socket -> {
            try {
                PrintWriter toClient =
                        new PrintWriter(
                                socket.getOutputStream(), true);

                BufferedReader fromClient =
                        new BufferedReader(
                                new InputStreamReader(
                                        socket.getInputStream()));

                logger.info("Connected to client: "
                        + socket.getRemoteSocketAddress());

                toClient.println("Hello from Server");

                String msg = fromClient.readLine();
                logger.info("Client said: " + msg);

                socket.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        };
    }

    public static void main(String[] args) throws Exception {

        Server server = new Server();
        ServerSocket serverSocket = new ServerSocket(8010);

        while (true) {
            Socket socket = serverSocket.accept();
            Thread thread =
                    new Thread(() ->
                            server.getConsumer().accept(socket));
            thread.start();
        }
    }
}

What Changed Here?

Instead of handling the client directly, we wrap the client logic inside a Runnable (via Consumer<Socket>). Every time a client connects, we create a new thread and start it.

Now:

  • one slow client does not block others

  • multiple clients can connect simultaneously

However, this introduces a new problem.


Why Creating Threads Manually Is Dangerous

Threads are expensive. Each thread consumes memory and CPU. If hundreds or thousands of clients connect, creating a new thread for each one can crash the server.

This is where thread pools become essential.


Part 3: Thread Pool Server (Production-Grade)

A thread pool reuses a fixed number of threads instead of creating new ones repeatedly.

Complete Server Code

package threadPool;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.logging.Logger;

public class Server {

    public static final Logger logger =
            Logger.getLogger(Server.class.getName());

    public class ClientHandler implements Runnable {

        private final Socket socket;

        ClientHandler(Socket socket) {
            this.socket = socket;
        }

        @Override
        public void run() {
            try {
                PrintWriter toClient =
                        new PrintWriter(
                                socket.getOutputStream(), true);

                BufferedReader fromClient =
                        new BufferedReader(
                                new InputStreamReader(
                                        socket.getInputStream()));

                logger.info("Handling client: "
                        + socket.getRemoteSocketAddress());

                toClient.println("Server saying hello!!");

                String msg = fromClient.readLine();
                logger.info("Client said: " + msg);

                socket.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) throws Exception {

        Server server = new Server();
        ServerSocket serverSocket = new ServerSocket(8010);

        ExecutorService pool =
                Executors.newFixedThreadPool(10);

        while (true) {
            Socket socket = serverSocket.accept();
            pool.submit(server.new ClientHandler(socket));
        }
    }
}

Understanding ExecutorService

ExecutorService manages:

  • a fixed number of worker threads

  • a task queue

  • thread reuse and scheduling

Instead of creating threads manually, we submit tasks:

pool.submit(new ClientHandler(socket));

A free worker thread executes run() and then returns to the pool. This gives us scalability and stability.


Why Sockets Still Must Be Closed

Thread pools manage threads, not network connections. Each socket represents a real OS-level resource. If sockets are not closed, the server will eventually run out of file descriptors.

That’s why:

socket.close();

is mandatory.


Final Takeaway

By building servers this way, we learn:

  • how ServerSocket listens

  • how accept() creates a socket per client

  • how streams enable communication

  • how logging helps in concurrency

  • how threads solve blocking

  • how thread pools make servers scalable

Frameworks hide these details—but they all rely on them.
Github URL: https://github.com/arshghaiwat/ClientServer.git