Java

Chapter 21: Java Networking – Low-Level Sockets to the Modern HTTP Client

By Ali Naqi | October 31, 2026

Chapter 21: Java Networking – Low-Level Sockets to the Modern HTTP Client

🌐 Chapter 21: Java Networking – Low-Level Sockets to the Modern HTTP Client

1. Network Communications: The OSI and Transport Layers

At the networking layer, distributed applications communicate via two primary transport protocols:

 * TCP (Transmission Control Protocol): Connection-oriented, ordered, reliable, and error-checked. Packets are tracked via handshakes and acknowledgments. If a packet drops in transit, TCP re-transmits it automatically. Used by HTTP, database drivers, SSH, and email.

 * UDP (User Datagram Protocol): Connectionless, fire-and-forget, lightweight, and unordered. Packets are sent without delivery guarantees. Used by DNS lookups, video streaming, and online multiplayer gaming where speed beats reliability.

In Java, TCP networking lives in java.net.Socket and java.net.ServerSocket.

Application Layer: [ HTTP / HTTPS / REST / WebSockets ]

                                  │

Transport Layer: [ TCP: Sockets & Streams ]

                                  │

Internet Layer: [ IP: Packets & Routing ]


2. Low-Level TCP Networking: Client-Server Sockets

To understand higher-level protocols like HTTP, you must first understand how raw TCP connections work.

 * ServerSocket: Runs on a host machine, binds to a designated port (e.g., 8080), and calls .accept()—a blocking call that waits for incoming connections from clients.

 * Socket: Initiates the TCP connection from a client machine to a host and port. Once connected, reading and writing use standard I/O Streams (InputStream and OutputStream, covered in Chapter 14).

Server Machine (Port 8080) Client Machine

┌─────────────────────────┐ ┌─────────────────────────┐

│ ServerSocket.accept() │ ◄────────── │ new Socket("host", 8080)│

│ (Awaits incoming TCP) │ │ (Initiates Handshake) │

└────────────┬────────────┘ └────────────┬────────────┘

             ▼ ▼

    Connected Socket Connected Socket

  [ InputStream / OutputStream ] ◄════► [ InputStream / OutputStream ]


Implementing a Minimal Multi-Threaded TCP Echo Server

import java.io.*;

import java.net.ServerSocket;

import java.net.Socket;

import java.nio.charset.StandardCharsets;


public class MinimalEchoServer {

    public static void main(String[] args) {

        int port = 9090;


        try (ServerSocket serverSocket = new ServerSocket(port)) {

            System.out.println("Echo server listening on port " + port);


            while (true) {

                // Blocks until a remote client connects

                Socket clientSocket = serverSocket.accept();

                System.out.println("Client connected: " + clientSocket.getRemoteSocketAddress());


                // Hand off connection to a Virtual Thread (Chapter 16)

                Thread.ofVirtual().start(() -> handleClient(clientSocket));

            }

        } catch (IOException e) {

            System.err.println("Server exception: " + e.getMessage());

        }

    }


    private static void handleClient(Socket socket) {

        try (socket;

             BufferedReader reader = new BufferedReader(

                 new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));

             BufferedWriter writer = new BufferedWriter(

                 new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8))) {


            String line;

            while ((line = reader.readLine()) != null) {

                if ("QUIT".equalsIgnoreCase(line.trim())) {

                    writer.write("Goodbye!\n");

                    writer.flush();

                    break;

                }

                // Echo the line back to client

                writer.write("ECHO: " + line + "\n");

                writer.flush();

            }

        } catch (IOException e) {

            System.err.println("Handler failure: " + e.getMessage());

        }

    }

}


The Matching TCP Client

import java.io.*;

import java.net.Socket;

import java.nio.charset.StandardCharsets;


public class MinimalEchoClient {

    public static void main(String[] args) {

        try (Socket socket = new Socket("localhost", 9090);

             BufferedReader console = new BufferedReader(new InputStreamReader(System.in));

             BufferedWriter out = new BufferedWriter(

                 new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8));

             BufferedReader in = new BufferedReader(

                 new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) {


            System.out.println("Connected to Echo server. Type a message (or 'QUIT'):");


            String userInput;

            while ((userInput = console.readLine()) != null) {

                out.write(userInput + "\n");

                out.flush();


                String serverResponse = in.readLine();

                System.out.println("Server replied: " + serverResponse);


                if ("QUIT".equalsIgnoreCase(userInput.trim())) break;

            }

        } catch (IOException e) {

            System.err.println("Client error: " + e.getMessage());

        }

    }

}


3. The Legacy HTTP Problem: HttpURLConnection

Early versions of Java included java.net.HttpURLConnection for HTTP communication. Created in the Java 1.1 era (the late 1990s), it suffered from major architectural flaws:

 * Blocking-only semantics: Tied up a thread for every active connection.

 * Awkward API design: Relied on cryptic header handling, manual output stream writing, and unclear redirects.

 * No HTTP/2 Support: Limited strictly to HTTP/1.1, missing out on modern multiplexing, stream prioritization, and server push.

Because of this, Java developers spent years relying on third-party libraries (like Apache HttpClient or OkHttp). In Java 11, the JDK resolved this by introducing a built-in, modern HTTP client: java.net.http.HttpClient.

4. The Modern HTTP Client: Core Triad

The java.net.http package is built around three core classes:

 * HttpClient: The engine and connection manager. Handles connection pooling, TLS/SSL context, cookie policies, proxy settings, and HTTP protocol negotiation (HTTP/1.1 vs. HTTP/2). It is immutable and thread-safe.

 * HttpRequest: Represents an outgoing HTTP request (URI, HTTP method, headers, timeout, and body payload).

 * HttpResponse<T>: Represents the returned HTTP response (status code, headers, URI, and converted body payload).

┌────────────────────────────────────────────────────────┐

│ java.net.http.HttpClient │

│ (Thread-Safe, Connection-Pooled, HTTP/1.1 & HTTP/2) │

└──────────────┬───────────────────────────┬─────────────┘

               │ │

    Synchronous (send) Asynchronous (sendAsync)

               │ │

               ▼ ▼

      [ HttpResponse<T> ] [ CompletableFuture<HttpResponse<T>> ]


5. Synchronous vs. Asynchronous Communication

HttpClient supports two execution models:

A. Synchronous (Blocking) Request

Calls .send(...), which blocks the calling thread until the HTTP headers and body arrive:

import java.io.IOException;

import java.net.URI;

import java.net.http.HttpClient;

import java.net.http.HttpRequest;

import java.net.http.HttpResponse;

import java.time.Duration;


public class SyncGetDemo {

    public static void main(String[] args) throws IOException, InterruptedException {

        // 1. Build client instance

        HttpClient client = HttpClient.newBuilder()

            .version(HttpClient.Version.HTTP_2)

            .connectTimeout(Duration.ofSeconds(5))

            .followRedirects(HttpClient.Redirect.NORMAL)

            .build();


        // 2. Build request

        HttpRequest request = HttpRequest.newBuilder()

            .uri(URI.create("https://httpbin.org/get"))

            .timeout(Duration.ofSeconds(10))

            .header("Accept", "application/json")

            .header("User-Agent", "Java21ModernClient/1.0")

            .GET()

            .build();


        // 3. Send blocking request, converting response body directly to a String

        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());


        System.out.println("Status Code: " + response.statusCode());

        System.out.println("Protocol: " + response.version());

        System.out.println("Response Payload:\n" + response.body());

    }

}


B. Asynchronous (Non-Blocking) Request with CompletableFuture

Calls .sendAsync(...), returning immediately with a CompletableFuture<HttpResponse<T>> (Chapter 16):

import java.net.URI;

import java.net.http.*;

import java.util.concurrent.CompletableFuture;


public class AsyncHttpDemo {

    public static void main(String[] args) {

        HttpClient client = HttpClient.newHttpClient();


        HttpRequest request = HttpRequest.newBuilder()

            .uri(URI.create("https://httpbin.org/delay/1"))

            .GET()

            .build();


        // Non-blocking invocation

        CompletableFuture<HttpResponse<String>> futureResponse = 

            client.sendAsync(request, HttpResponse.BodyHandlers.ofString());


        System.out.println("Request fired asynchronously. Main thread is free!");


        futureResponse

            .thenApply(HttpResponse::body)

            .thenAccept(body -> System.out.println("Async payload received:\n" + body))

            .exceptionally(ex -> {

                System.err.println("Request failed: " + ex.getMessage());

                return null;

            });


        // Block main thread long enough to view async output

        futureResponse.join();

    }

}


6. Body Handlers and Body Publishers

The modern HTTP API separates how data is sent from how data is received using reactive streams:

 * HttpRequest.BodyPublishers: Converts Java data into an outgoing stream of bytes.

 * HttpResponse.BodyHandlers: Converts an incoming stream of bytes into a structured Java type.

Common BodyPublishers (Sending Data)

 * BodyPublishers.noBody(): For GET or DELETE requests.

 * BodyPublishers.ofString(String text): For JSON, XML, or plain text strings.

 * BodyPublishers.ofFile(Path path): Streams a file from disk into the request body without loading it entirely into RAM.

 * BodyPublishers.ofByteArray(byte[] data): Sends raw binary payloads.

Common BodyHandlers (Receiving Data)

 * BodyHandlers.ofString(): Returns response body as a single String.

 * BodyHandlers.ofFile(Path destination): Streams incoming bytes directly to disk (ideal for downloading large files).

 * BodyHandlers.ofLines(): Streams the response body as a Stream<String>, line by line.

 * BodyHandlers.discarding(): Drops the body bytes (useful for health-checks or verifying status codes without memory overhead).

7. Real-Time Bi-Directional Communication: WebSockets

Standard HTTP follows a request-response cycle: the client asks, the server answers, and the connection closes or returns to a pool.

WebSockets (java.net.http.WebSocket) upgrade an existing HTTP connection into a long-lived, full-duplex, bi-directional TCP channel. Both the client and the server can send messages to each other at any time with minimal header overhead.

Client Server

  │ │

  ├─── Standard HTTP Handshake (Upgrade: websocket) ────► │

  │ ◄─ 101 Switching Protocols (Connection Established) ──┤

  │ │

  │ ══════════ FULL DUPLEX REAL-TIME CHANNEL ════════════ │

  │ ◄──────────────── Text / Binary Frame ─────────────── │

  │ ───────────────── Text / Binary Frame ──────────────► │

  │ ◄──────────────── Text / Binary Frame ─────────────── │


Implementing a WebSocket Client Listener

import java.net.URI;

import java.net.http.HttpClient;

import java.net.http.WebSocket;

import java.nio.ByteBuffer;

import java.util.concurrent.CompletionStage;


public class WebSocketClientDemo {


    // Listener receives asynchronous lifecycle events from the server

    private static class WebSocketReceiver implements WebSocket.Listener {

        @Override

        public void onOpen(WebSocket webSocket) {

            System.out.println("[WS] Channel established!");

            webSocket.sendText("Hello from Java 21 WebSocket Client!", true);

            WebSocket.Listener.super.onOpen(webSocket);

        }


        @Override

        public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {

            System.out.println("[WS Received]: " + data);

            return WebSocket.Listener.super.onText(webSocket, data, last);

        }


        @Override

        public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {

            System.out.println("[WS Closed] Status: " + statusCode + ", Reason: " + reason);

            return WebSocket.Listener.super.onClose(webSocket, statusCode, reason);

        }


        @Override

        public void onError(WebSocket webSocket, Throwable error) {

            System.err.println("[WS Error]: " + error.getMessage());

        }

    }


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

        HttpClient client = HttpClient.newHttpClient();


        // Connect to a public echo WebSocket server

        CompletableFuture<WebSocket> wsFuture = client.newWebSocketBuilder()

            .buildAsync(URI.create("wss://echo.websocket.org"), new WebSocketReceiver());


        WebSocket webSocket = wsFuture.join();


        // Send a message over the live channel

        webSocket.sendText("Streaming telemetry pulse...", true);


        Thread.sleep(3000);


        // Clean close handshake

        webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Session completed").join();

    }

}


8. Practical Example: Building an Automated REST Microservice Client

Let's combine Java Records, Modern HTTP Client, Text Blocks, and Asynchronous Pipelines into an enterprise-grade REST API client with timeout protection and structured error handling.

import java.io.IOException;

import java.net.URI;

import java.net.http.*;

import java.time.Duration;

import java.util.concurrent.CompletableFuture;


// Domain records matching API JSON payloads

record PostRequest(String title, String body, int userId) {}

record PostResponse(int id, String title, String body, int userId) {}


public class RestApiClient {


    private final HttpClient httpClient;

    private final String baseUrl;


    public RestApiClient(String baseUrl) {

        this.baseUrl = baseUrl;

        this.httpClient = HttpClient.newBuilder()

            .version(HttpClient.Version.HTTP_2)

            .connectTimeout(Duration.ofSeconds(5))

            .build();

    }


    // POST: Create resource

    public CompletableFuture<String> createPostAsync(PostRequest post) {

        // Build JSON body using Text Blocks (Chapter 18)

        String requestJson = """

            {

              "title": "%s",

              "body": "%s",

              "userId": %d

            }

            """.formatted(post.title(), post.body(), post.userId());


        HttpRequest request = HttpRequest.newBuilder()

            .uri(URI.create(baseUrl + "/posts"))

            .timeout(Duration.ofSeconds(8))

            .header("Content-Type", "application/json; charset=UTF-8")

            .POST(HttpRequest.BodyPublishers.ofString(requestJson))

            .build();


        return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())

            .thenApply(response -> {

                if (response.statusCode() >= 200 && response.statusCode() < 300) {

                    return response.body();

                } else {

                    throw new RuntimeException("API error: Status " + response.statusCode() + " -> " + response.body());

                }

            });

    }


    // GET: Stream file directly to disk to prevent RAM exhaustion

    public void downloadAttachment(String endpoint, java.nio.file.Path targetPath) 

            throws IOException, InterruptedException {


        HttpRequest request = HttpRequest.newBuilder()

            .uri(URI.create(baseUrl + endpoint))

            .GET()

            .build();


        HttpResponse<java.nio.file.Path> response = 

            httpClient.send(request, HttpResponse.BodyHandlers.ofFile(targetPath));


        if (response.statusCode() == 200) {

            System.out.println("Asset downloaded to: " + response.body().toAbsolutePath());

        } else {

            System.err.println("Download failed with HTTP code: " + response.statusCode());

        }

    }


    public static void main(String[] args) {

        RestApiClient client = new RestApiClient("https://jsonplaceholder.typicode.com");


        PostRequest newPost = new PostRequest("Modern Java Networking", "Exploring java.net.http in depth", 42);


        System.out.println("Submitting post to API...");

        client.createPostAsync(newPost)

            .thenAccept(responsePayload -> {

                System.out.println("Successfully created resource!");

                System.out.println(responsePayload);

            })

            .exceptionally(ex -> {

                System.err.println("Pipeline failed: " + ex.getMessage());

                return null;

            })

            .join();

    }

}


📝 Chapter Summary

 * TCP Sockets (Socket and ServerSocket) provide low-level, stream-based byte communication across raw network ports.

 * Avoid legacy HttpURLConnection; it is blocking-only, lacks HTTP/2 support, and requires verbose boilerplate.

 * java.net.http.HttpClient is the standard for modern HTTP in Java:

   * Thread-safe, connection-pooled, and supports both HTTP/1.1 and HTTP/2.

   * Supports both synchronous blocking (client.send()) and asynchronous reactive (client.sendAsync()) pipelines via CompletableFuture.

 * BodyPublishers convert outgoing Java objects into byte streams; BodyHandlers process incoming bytes into strings, files, or line streams.

 * Use BodyHandlers.ofFile() when downloading files to stream content directly to disk without loading it into JVM heap memory.

 * WebSockets (client.newWebSocketBuilder()) provide long-lived, full-duplex bi-directional communication channels for real-time applications.