Building a Java Server from Scratch
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:
It binds itself to a port
It waits for a client to connect
It creates a connection object for that client
It exchanges data using streams
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:
ServerSocketis long-lived and sharedSocketis 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 streamPrintWriterlets us write texttrueenables 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:
InputStreamreads raw bytesInputStreamReaderconverts bytes to charactersBufferedReaderallows 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
ServerSocketlistenshow
accept()creates a socket per clienthow 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