Networking & HTTP

Sockets: TCP Client & Server

15 min Lesson 2 of 13

Sockets: TCP Client & Server

TCP is the backbone of the internet's reliable communication: it guarantees delivery, ordering, and error detection. In Java, the java.net package exposes TCP through two classes — Socket (the client side) and ServerSocket (the listener). Understanding how they work together, and how data flows through the streams they vend, is fundamental to everything from building your own protocol to understanding how HTTP and database drivers actually work underneath.

How TCP Works in One Paragraph

A TCP connection is a bidirectional, byte-stream channel between two endpoints (IP address + port). The server binds a port and listens. The client initiates a three-way handshake. Once the handshake completes, both sides have a connected socket they can read from and write to independently. Data is delivered in order; the OS buffers and retransmits lost segments transparently. This reliability is what distinguishes TCP from UDP.

ServerSocket: The Listening Side

ServerSocket owns a port and blocks on accept() until a client connects. Each call to accept() returns a new Socket representing that one client — the server socket itself keeps listening. The classic pattern is to hand off each accepted socket to a thread or executor so the server can handle multiple clients concurrently.

import java.io.*; import java.net.*; import java.util.concurrent.*; public class EchoServer { public static void main(String[] args) throws IOException { int port = 9000; ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor(); // Java 21+ try (ServerSocket serverSocket = new ServerSocket(port)) { System.out.println("Echo server listening on port " + port); while (true) { // accept loop Socket client = serverSocket.accept(); // blocks until a client connects pool.submit(() -> handleClient(client)); } } } private static void handleClient(Socket socket) { try (socket; BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out = new PrintWriter(socket.getOutputStream(), true)) { String line; while ((line = in.readLine()) != null) { // null = client closed the connection System.out.println("Received: " + line); out.println("ECHO: " + line); // auto-flush because second arg = true } } catch (IOException e) { System.err.println("Client error: " + e.getMessage()); } } }
Virtual threads (Java 21+): Executors.newVirtualThreadPerTaskExecutor() gives you one lightweight virtual thread per client at near-zero overhead — no thread-pool sizing needed. For Java 17/19 use Executors.newCachedThreadPool() instead.

Socket: The Client Side

The client constructs a Socket with the server's hostname and port. The constructor itself performs the TCP three-way handshake; if the server is unreachable or the port is closed, it throws a ConnectException. Once constructed, reading and writing happen through InputStream/OutputStream, exactly like any other I/O in 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); BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out = new PrintWriter(socket.getOutputStream(), true); BufferedReader console = new BufferedReader(new InputStreamReader(System.in))) { System.out.println("Connected. Type messages (blank line to quit):"); String userLine; while (!(userLine = console.readLine()).isBlank()) { out.println(userLine); // send to server System.out.println(in.readLine()); // read echo back } } // try-with-resources closes the socket, which sends TCP FIN to the server } }

Stream Wrapping: Why It Matters

socket.getInputStream() returns a raw InputStream. Reading raw bytes directly is tedious and error-prone for text protocols. Layering wrappers adds framing and charset handling:

  • InputStreamReader — decodes bytes to chars using a charset (always specify StandardCharsets.UTF_8 explicitly).
  • BufferedReader — adds buffering and the critical readLine(), which reads until \n or \r\n.
  • PrintWriter(out, true) — the second true enables auto-flush after every println(). Without it, data stays in the buffer and the peer never receives it.
// Always specify the charset — do not rely on platform default BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter out = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true);
Forgetting to flush is the single most common TCP socket bug. If you use PrintWriter without auto-flush, or an OutputStream without calling flush(), data sits in the OS send buffer and the server never receives it — the program just hangs waiting forever.

Resource Management and Half-Close

A Socket holds a file descriptor. Always close it in a finally block or, better, use try-with-resources. Closing the socket sends a TCP FIN, signalling the peer that no more data will be sent. The peer's readLine() then returns null, letting it exit its read loop cleanly.

You can also half-close: call socket.shutdownOutput() to signal that you are done writing while still being able to read. This is how HTTP/1.0 clients signal end-of-request without dropping the connection.

// Half-close: signal end of write stream, keep reading socket.shutdownOutput(); // sends TCP FIN in the write direction String serverResponse = in.readLine(); // can still read

Timeouts: Mandatory in Production Code

By default Socket blocks indefinitely on connect() and read(). A server that stops responding will hang your thread forever. Always set timeouts:

  • Connect timeout: use the two-argument connect() overload rather than the constructor.
  • Read timeout (SO_TIMEOUT): socket.setSoTimeout(millis) — throws SocketTimeoutException if a read blocks longer than the limit.
Socket socket = new Socket(); socket.setSoTimeout(5_000); // 5 s read timeout socket.connect(new InetSocketAddress("localhost", 9000), 3_000); // 3 s connect timeout try (socket) { // use the socket ... } catch (SocketTimeoutException e) { System.err.println("Server did not respond in time"); }

Backlog: The Accept Queue

The second parameter of new ServerSocket(port, backlog) controls how many not-yet-accepted connections the OS queues. If the accept loop is slow and the queue fills up, new connection attempts are rejected with "connection refused". The default is 50, which is usually fine for low-traffic servers but too small for high-throughput scenarios where you should accept connections as fast as possible and defer work to threads.

Key Trade-offs at a Glance

  • One-thread-per-client vs. NIO (non-blocking): Virtual threads make one-thread-per-client practical again for most servers; NIO Selector-based servers are still superior when you have hundreds of thousands of idle long-lived connections (e.g., chat servers).
  • Text vs. binary protocols: BufferedReader/PrintWriter work well for line-delimited text. For binary or length-prefixed protocols use DataInputStream/DataOutputStream or ObjectInputStream/ObjectOutputStream.
  • Plain sockets vs. SSLSocket: Never transmit sensitive data over a plain socket. Wrap with SSLSocketFactory to get TLS — the API is the same, just with an additional handshake.
Test your server without writing a client: telnet localhost 9000 or nc localhost 9000 lets you type lines and see responses immediately, which speeds up debugging dramatically.

Summary

ServerSocket binds a port; each call to accept() returns a Socket for one client. Wrap the socket's streams in BufferedReader/PrintWriter for text protocols, always specify UTF-8, and always enable auto-flush. Hand each accepted socket off to a virtual thread or executor for concurrency. Set connect and read timeouts in every production client. Close sockets with try-with-resources so the TCP FIN propagates cleanly. These fundamentals underpin every higher-level Java network library you will ever use.