Sockets: TCP Client & Server
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.
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.
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 specifyStandardCharsets.UTF_8explicitly).BufferedReader— adds buffering and the criticalreadLine(), which reads until\nor\r\n.PrintWriter(out, true)— the secondtrueenables auto-flush after everyprintln(). Without it, data stays in the buffer and the peer never receives it.
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.
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)— throwsSocketTimeoutExceptionif a read blocks longer than the limit.
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/PrintWriterwork well for line-delimited text. For binary or length-prefixed protocols useDataInputStream/DataOutputStreamorObjectInputStream/ObjectOutputStream. - Plain sockets vs.
SSLSocket: Never transmit sensitive data over a plain socket. Wrap withSSLSocketFactoryto get TLS — the API is the same, just with an additional handshake.
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.