TCP Socket Programming in Java: ServerSocket, Socket & Client/Server Patterns

TCP sockets give you a reliable, ordered, bidirectional byte stream between two endpoints. Java's java.net package wraps the OS socket API into ServerSocket (listener) and Socket (connection). This article covers everything from a minimal echo server to a thread-pool server with graceful shutdown.

Share

TCP Fundamentals & the Java Socket API

TCP (Transmission Control Protocol) provides:

  • Reliability: lost packets are automatically retransmitted.
  • Ordering: bytes arrive in the order they were sent.
  • Flow & congestion control: prevents sender from overwhelming receiver or network.

The Java classes you need are all in java.net:

ClassRole
ServerSocketBinds to a port and blocks on accept() until a client connects
SocketRepresents one end of a TCP connection: used by both client and accepted server side
InetAddressResolves hostnames to IP addresses
InetSocketAddressHost + port pair for binding and connecting

Minimal Echo Server

The simplest possible server: accept one connection, echo every line back, then close.

Java
import java.io.*;
import java.net.*;

public class EchoServer {
    public static void main(String[] args) throws IOException {
        // bind to port 9000, backlog = 50 pending connections
        try (ServerSocket server = new ServerSocket(9000, 50)) {
            System.out.println("Listening on port 9000...");
            try (Socket client = server.accept()) {           // blocks here
                System.out.println("Connected: " + client.getRemoteSocketAddress());
                var in  = new BufferedReader(new InputStreamReader(client.getInputStream()));
                var out = new PrintWriter(client.getOutputStream(), true);
                String line;
                while ((line = in.readLine()) != null) {
                    out.println("ECHO: " + line);
                }
            }
        }
    }
}

And the matching client:

Java
import java.io.*;
import java.net.*;

public class EchoClient {
    public static void main(String[] args) throws IOException {
        try (Socket socket = new Socket("localhost", 9000);
             var out = new PrintWriter(socket.getOutputStream(), true);
             var in  = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {

            out.println("Hello, server!");
            System.out.println(in.readLine());   // ECHO: Hello, server!
        }
    }
}

Thread-Per-Client Server

A single-threaded server can only serve one client at a time. The classic fix is to spawn a thread for each accepted connection.

Java
import java.io.*;
import java.net.*;

public class ThreadedEchoServer {

    public static void main(String[] args) throws IOException {
        try (ServerSocket server = new ServerSocket(9000)) {
            System.out.println("Threaded echo server started on port 9000");
            while (true) {
                Socket client = server.accept();   // do NOT close here
                new Thread(() -> handle(client)).start();
            }
        }
    }

    private static void handle(Socket client) {
        try (client;
             var in  = new BufferedReader(new InputStreamReader(client.getInputStream()));
             var out = new PrintWriter(client.getOutputStream(), true)) {

            System.out.println("New client: " + client.getRemoteSocketAddress());
            String line;
            while ((line = in.readLine()) != null) {
                out.println("ECHO: " + line);
            }
        } catch (IOException e) {
            System.err.println("Client error: " + e.getMessage());
        }
    }
}
Spawning an unbounded thread per connection does not scale. For high concurrency use a thread pool (see below) or switch to NIO non-blocking I/O.

Thread-Pool Server with ExecutorService

Cap the concurrency by submitting each accepted connection to a fixed-size thread pool.

Java
import java.io.*;
import java.net.*;
import java.util.concurrent.*;

public class PooledEchoServer {

    private static final int PORT     = 9000;
    private static final int THREADS  = 50;

    public static void main(String[] args) throws IOException {
        ExecutorService pool = Executors.newFixedThreadPool(THREADS);

        try (ServerSocket server = new ServerSocket(PORT)) {
            System.out.printf("Pool server on port %d (threads=%d)%n", PORT, THREADS);

            // register shutdown hook for graceful stop
            Runtime.getRuntime().addShutdownHook(new Thread(() -> {
                pool.shutdown();
                System.out.println("Pool shut down.");
            }));

            while (!Thread.currentThread().isInterrupted()) {
                Socket client = server.accept();
                pool.submit(() -> handle(client));
            }
        }
    }

    private static void handle(Socket client) {
        try (client;
             var in  = new BufferedReader(new InputStreamReader(client.getInputStream()));
             var out = new PrintWriter(client.getOutputStream(), true)) {

            String line;
            while ((line = in.readLine()) != null) {
                out.println("ECHO: " + line);
            }
        } catch (IOException e) {
            System.err.println(e.getMessage());
        }
    }
}

Socket Options & Tuning

Important socket options to set before connecting or accepting:

OptionMethodPurpose
SO_TIMEOUTsocket.setSoTimeout(ms)Read timeout; throws SocketTimeoutException if exceeded
TCP_NODELAYsocket.setTcpNoDelay(true)Disable Nagle's algorithm for low-latency messaging
SO_KEEPALIVEsocket.setKeepAlive(true)Send TCP keep-alive probes to detect dead peers
SO_REUSEADDRserver.setReuseAddress(true)Allow re-bind while port is in TIME_WAIT (important on restart)
SO_RCVBUF / SO_SNDBUFsocket.setReceiveBufferSize(n)Tune OS socket buffer sizes for throughput
SO_LINGERsocket.setSoLinger(true, 0)RST instead of FIN on close: avoids TIME_WAIT accumulation
Java
ServerSocket server = new ServerSocket();
server.setReuseAddress(true);
server.bind(new InetSocketAddress(9000));

Socket client = server.accept();
client.setSoTimeout(30_000);       // 30 s read timeout
client.setTcpNoDelay(true);
client.setKeepAlive(true);

Sending Structured Data: DataInputStream / DataOutputStream

For binary protocols use DataInputStream and DataOutputStream, which can read/write primitives in a defined byte order.

Java
// Server: read a 4-byte length prefix then a UTF-8 message
try (Socket client = server.accept();
     var din  = new DataInputStream(client.getInputStream());
     var dout = new DataOutputStream(client.getOutputStream())) {

    int length = din.readInt();               // 4-byte big-endian length
    byte[] body = din.readNBytes(length);
    String msg = new String(body, StandardCharsets.UTF_8);
    System.out.println("Received: " + msg);

    // echo back
    byte[] response = ("ACK: " + msg).getBytes(StandardCharsets.UTF_8);
    dout.writeInt(response.length);
    dout.write(response);
    dout.flush();
}
Always define a framing protocol (length prefix, delimiter, fixed width) before sending data over a raw TCP stream. TCP may split or coalesce writes: you cannot assume one write() = one read().

Graceful Shutdown Pattern

To stop a server cleanly: set a flag, interrupt the accept loop by closing the ServerSocket, then await thread pool termination.

Java
public class GracefulServer {
    private final ServerSocket serverSocket;
    private final ExecutorService pool = Executors.newFixedThreadPool(20);
    private volatile boolean running = true;

    public GracefulServer(int port) throws IOException {
        serverSocket = new ServerSocket(port);
    }

    public void start() {
        while (running) {
            try {
                Socket client = serverSocket.accept();
                pool.submit(() -> handle(client));
            } catch (SocketException e) {
                if (!running) break;   // expected on shutdown
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    public void stop() throws IOException, InterruptedException {
        running = false;
        serverSocket.close();       // unblocks accept()
        pool.shutdown();
        pool.awaitTermination(10, TimeUnit.SECONDS);
    }

    private void handle(Socket client) { /* ... */ }
}

Common Pitfalls

PitfallFix
Forgetting to close sockets → file descriptor leakAlways use try-with-resources
Reading partial data because TCP split the writeUse a length-prefix framing protocol
Connection refused immediatelyServer not started, wrong port, firewall blocking
Port already in use on restartCall server.setReuseAddress(true) before bind
Blocking reads hang foreverSet socket.setSoTimeout(ms)
Unbounded threads: OOM under loadUse a fixed-size ExecutorService
PrintWriter silently swallows exceptionsCheck out.checkError() or use streams directly