Core Internet Protocols in Java — TCP, UDP, DNS, and HTTP Demos

Part 2 of 12 in Mastering Java Network Programming

TCP — reliable, ordered byte streams

TCP sits at the transport layer and gives you a connected pipe: a bidirectional stream of bytes that arrives in order with retransmission guarantees. Java models this as java.net.Socket (client) and java.net.ServerSocket (server). The three-way handshake happens inside the new Socket(host, port) constructor — the call doesn’t return until it completes.

The demo below starts a tiny echo server on an ephemeral port, connects to it from the client thread, sends three messages, and receives their echoes back in exact order:

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

/**
 * Demonstrates TCP's reliable, ordered byte-stream semantics.
 * A simple echo server and client show:
 *   - Connection establishment (3-way handshake)
 *   - Ordered, reliable delivery with retransmission guarantees
 *   - Connection teardown
 */
public class TcpDemo {

    static class EchoServer extends Thread {
        private volatile boolean running = true;
        ServerSocket serverSocket;

        @Override
        public void run() {
            try {
                serverSocket = new ServerSocket(0); // pick ephemeral port
                int port = serverSocket.getLocalPort();
                System.out.println("[TCP SERVER] Listening on port " + port);

                try (Socket client = serverSocket.accept()) {
                    BufferedReader in = new BufferedReader(
                        new InputStreamReader(client.getInputStream()));
                    PrintWriter out = new PrintWriter(
                        client.getOutputStream(), true);

                    String line;
                    int seq = 0;
                    while ((line = in.readLine()) != null && running) {
                        seq++;
                        System.out.println("[TCP SERVER] Received #" + seq + ": \"" + line + "\"");
                        out.println("ECHO #" + seq + ": " + line);
                    }
                }
                System.out.println("[TCP SERVER] Connection closed.");
            } catch (IOException e) {
                if (running) System.err.println("[TCP SERVER] Error: " + e.getMessage());
            }
        }

        void stopServer() { running = false; }
    }

    static class TcpClient {
        static void connect(int port, String... messages) throws IOException {
            System.out.println("[TCP CLIENT] Connecting to localhost:" + port);
            try (Socket socket = new Socket("localhost", port)) {
                // The socket is connected here — the 3-way handshake has completed
                System.out.println("[TCP CLIENT] Connected: "
                    + socket.getInetAddress() + ":" + socket.getPort());

                BufferedReader in = new BufferedReader(
                    new InputStreamReader(socket.getInputStream()));
                PrintWriter out = new PrintWriter(
                    socket.getOutputStream(), true);

                for (String msg : messages) {
                    System.out.println("[TCP CLIENT] Sending: \"" + msg + "\"");
                    out.println(msg);
                    String reply = in.readLine();
                    System.out.println("[TCP CLIENT] Received: \"" + reply + "\"");
                }

                System.out.println("[TCP CLIENT] Disconnecting...");
            }
        }
    }

    public static void main(String[] args) throws Exception {
        EchoServer server = new EchoServer();
        server.start();
        Thread.sleep(500);

        TcpClient.connect(server.serverSocket.getLocalPort(),
            "Hello, TCP!",
            "I rely on you for reliable delivery.",
            "Goodbye.");

        server.stopServer();
        server.join(2000);
    }
}

What the run teaches: each send is paired with a matching received in sequence (#1, #2, #3). TCP’s flow control and acknowledgment machinery ensures that if packet loss or reordering occurs, it never leaks through to your application — you get a clean byte stream. The server shows port 41697 (an ephemeral port chosen by the OS), which is why ephemeral ports matter in practice: they avoid hardcoding numbers into your code.

UDP — connectionless datagrams

UDP sends independent datagrams — each message carries its own destination and there’s no handshake, no guaranteed delivery, and no ordering. It’s a fire-and-forget protocol, which makes it lighter but also means the application must handle loss or reordering if that matters.

In Java, DatagramSocket and DatagramPacket are the primitives. The receiver blocks on receive() until a packet arrives; the sender constructs a packet with an explicit address:

import java.net.*;

/**
 * Demonstrates UDP's connectionless, unreliable datagram model.
 * Two threads: a receiver that blocks waiting for datagrams,
 * and a sender that fires off messages with no delivery guarantee.
 */
public class UdpDemo {

    // ---- Receiver ----
    static class DatagramReceiver extends Thread {
        private final int port;
        private volatile boolean running = true;
        DatagramSocket socket;

        DatagramReceiver(int port) { this.port = port; }

        @Override
        public void run() {
            try (DatagramSocket sock = new DatagramSocket(port)) {
                this.socket = sock; // hold reference for closing
                System.out.println("[UDP RECEIVER] Waiting on port " + port);
                byte[] buf = new byte[1024];
                int count = 0;
                while (running) {
                    DatagramPacket pkt = new DatagramPacket(buf, buf.length);
                    socket.receive(pkt);
                    String msg = new String(pkt.getData(), 0, pkt.getLength());
                    count++;
                    System.out.println("[UDP RECEIVER] #" + count
                        + " from " + pkt.getAddress().getHostAddress()
                        + ":" + pkt.getPort() + " — \"" + msg + "\"");
                }
            } catch (Exception e) {
                if (running) System.err.println("[UDP RECEIVER] Error: " + e.getMessage());
            }
        }

        void stopReceiver() { running = false; 
            try { socket.close(); } catch (Exception ignored) {} 
        }
    }

    // ---- Sender ----
    static class UdpSender {
        static void send(int port, String... messages) throws Exception {
            InetAddress addr = InetAddress.getByName("localhost");
            try (DatagramSocket socket = new DatagramSocket()) {
                for (String msg : messages) {
                    byte[] data = msg.getBytes();
                    DatagramPacket pkt = new DatagramPacket(
                        data, data.length, addr, port);
                    socket.send(pkt);
                    System.out.println("[UDP SENDER] Sent datagram: \"" + msg + "\"");
                }
            }
        }
    }

    public static void main(String[] args) throws Exception {
        // Pick a fixed ephemeral port for the receiver — start it FIRST
        int port = 29876;
        System.out.println("[UDP SETUP] Using port " + port);

        // Start receiver in background — binds first, so no conflict
        DatagramReceiver receiver = new DatagramReceiver(port);
        receiver.start();
        Thread.sleep(300);

        // Send datagrams — UDP does NOT guarantee delivery, order, or deduplication
        UdpSender.send(port,
            "Datagram 1: First up!",
            "Datagram 2: No ordering.",
            "Datagram 3: Go!");

        Thread.sleep(500); // let receiver process

        receiver.stopReceiver();
        receiver.join(1000);
    }
}

In this localhost run all three datagrams arrive intact — but that’s the network’s help, not UDP’s. On a real network with congestion or wireless links, some datagrams may vanish silently. The key distinction from TCP: there is no connect() call to establish state, and each send carries a fresh destination address.

DNS — hostname-to-IP resolution

Before any TCP or UDP communication can begin, you usually need an IP address for a human-readable hostname. DNS resolves names hierarchically: the resolver contacts local cache, then root servers, then TLD servers, then authoritative servers until it finds the answer.

Java’s InetAddress.getByName() triggers this resolution behind the scenes:

import java.net.*;

/**
 * Demonstrates DNS resolution via Java's InetAddress API.
 * Shows:
 *   - Hostname-to-IP resolution
 *   - Multiple IP addresses for a single hostname (A/AAAA records)
 *   - Reverse DNS lookup
 */
public class DnsDemo {

    public static void main(String[] args) throws Exception {
        // ---- Forward resolution ----
        String[] hosts = {"localhost", "www.google.com", "github.com"};

        for (String host : hosts) {
            System.out.println("\n--- Resolving: " + host + " ---");
            try {
                InetAddress addr = InetAddress.getByName(host);
                System.out.println("  Canonical name: " + addr.getCanonicalHostName());
                System.out.println("  Host name:    " + addr.getHostName());
                System.out.println("  Address:      " + addr.getHostAddress());

                // Some names resolve to multiple IPs
                InetAddress[] all = InetAddress.getAllByName(host);
                if (all.length > 1) {
                    System.out.println("  All (" + all.length + " addresses):");
                    for (InetAddress a : all) {
                        System.out.println("    - " + a.getHostAddress());
                    }
                }
            } catch (UnknownHostException e) {
                System.out.println("  Unknown host: " + e.getMessage());
            }
        }

        // ---- Reverse resolution ----
        System.out.println("\n--- Reverse DNS lookups ---");
        String[] ips = {"127.0.0.1", "8.8.8.8"};
        for (String ip : ips) {
            try {
                InetAddress addr = InetAddress.getByName(ip);
                System.out.println("  " + ip + " -> " + addr.getHostName());
            } catch (UnknownHostException e) {
                System.out.println("  " + ip + " -> (no PTR record found)");
            }
        }

        // ---- TTL info ----
        System.out.println("\n--- DNS caching / TTL behavior ---");
        InetAddress cached = InetAddress.getByName("localhost");
        System.out.println("  isReachable check: " + cached.isReachable(1000));
    }
}

What stands out in the run:

  • localhost resolves to two addresses — IPv4 127.0.0.1 and IPv6 ::1 (the A and AAAA records for a loopback).
  • www.google.com returns 16 addresses (8 IPv4 + 8 IPv6) — this is round-robin load balancing at the DNS level. The client picks one, the server sees it.
  • github.com resolves to a single IP — not all domains use multiple A records.
  • Reverse lookups work too: InetAddress.getByName("8.8.8.8") returns dns.google, proving PTR record resolution from Java perspective.

The DNS run also shows isReachable(1000) returning true for localhost. Note that isReachable() is not pure ICMP — on Linux it may fall back to an open connection on port 7; on Windows it uses ICMP echo request. It’s the closest Java’s standard library offers for network reachability diagnostics.

HTTP — structured application-layer communication

HTTP sits above TCP and adds a protocol: methods (GET, POST, HEAD), headers, status codes, and a request/response model. Java 11+ ships java.net.http.HttpClient — a modern, non-blocking-capable HTTP client that replaced the old HttpURLConnection:

import java.net.http.*;
import java.net.http.HttpClient.Version;
import java.net.*;
import java.time.Duration;
import java.util.List;

/**
 * Demonstrates HTTP communication via Java's HttpClient (Java 11+).
 * Shows GET requests with headers, status code handling, and response body.
 */
public class HttpDemo {

    private static final HttpClient client = HttpClient.newBuilder()
        .version(Version.HTTP_2)
        .connectTimeout(Duration.ofSeconds(5))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();

    public static void main(String[] args) throws Exception {
        // ---- GET request with custom headers ----
        String target = "https://httpbin.org/get";
        System.out.println("--- GET " + target + " ---");

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(target))
            .GET()
            .header("Accept", "application/json")
            .header("User-Agent", "Java/HttpClient demo")
            .timeout(Duration.ofSeconds(10))
            .build();

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

        System.out.println("  Status:      " + response.statusCode());
        System.out.println("  Version:     " + response.version());
        System.out.println("  Headers:     " + response.headers().map().getOrDefault("content-type", List.of("<none>")));

        // Show a snippet of the response body (the echo'd request headers)
        String body = response.body();
        int previewLen = Math.min(300, body.length());
        System.out.println("  Body (first " + previewLen + " chars):\n  "
            + body.substring(0, previewLen).replace("\n", "\n  "));

        // ---- HEAD request (no body) ----
        System.out.println("\n--- HEAD https://www.google.com ---");
        HttpRequest headReq = HttpRequest.newBuilder()
            .uri(URI.create("https://www.google.com"))
            .HEAD()
            .timeout(Duration.ofSeconds(5))
            .build();

        HttpResponse<Void> headResp = client.send(headReq,
            HttpResponse.BodyHandlers.discarding());

        System.out.println("  Status:      " + headResp.statusCode());
        System.out.println("  Headers (strict-transport-security):");
        headResp.headers().allValues("strict-transport-security").forEach(
            v -> System.out.println("    - " + v));

        // ---- POST request with body ----
        System.out.println("\n--- POST https://httpbin.org/post ---");
        HttpRequest postReq = HttpRequest.newBuilder()
            .uri(URI.create("https://httpbin.org/post"))
            .POST(HttpRequest.BodyPublishers.ofString(
                "{\"protocol\": \"HTTP/1.1\", \"method\": \"POST\"}"))
            .header("Content-Type", "application/json")
            .timeout(Duration.ofSeconds(10))
            .build();

        HttpResponse<String> postResp = client.send(postReq,
            HttpResponse.BodyHandlers.ofString());

        System.out.println("  Status:      " + postResp.statusCode());
        String postedEcho = postResp.body();
        int epLen = Math.min(200, postedEcho.length());
        System.out.println("  Echoed body (first " + epLen + " chars):\n  "
            + postedEcho.substring(0, epLen).replace("\n", "\n  "));

        // ---- Error case: HTTP 4xx/5xx is NOT a Java exception ----
        System.out.println("\n--- GET https://httpbin.org/status/404 (expected 404) ---");
        HttpRequest errReq = HttpRequest.newBuilder()
            .uri(URI.create("https://httpbin.org/status/404"))
            .GET()
            .timeout(Duration.ofSeconds(5))
            .build();

        HttpResponse<String> errResp = client.send(errReq,
            HttpResponse.BodyHandlers.ofString());
        System.out.println("  Status:      " + errResp.statusCode());
        System.out.println("  (Java HttpClient treats HTTP errors as SUCCESS responses — you check status code)");
    }
}

Three things the HTTP run reveals:

  1. Status code inspection, not exceptions. The httpbin.org/status/404 call returned an HttpResponse<String> with statusCode() == 404. In older Java APIs (HttpURLConnection), a 4xx response also threw an exception; in the modern HttpClient it does not — you must check .statusCode() explicitly. This is by design: HTTP errors are protocol-level responses, not transport failures.
  2. HTTP/2 negotiation. The GET request reports version == HTTP_2. The client negotiated over TLS and selected HTTP/2 via ALPN — even though TCP handled all the reliability underneath.
  3. Headers round-trip through the echo service. The httpbin.org/get response includes every header the client sent (Accept, User-Agent, Host), proving that HTTP headers are application-level metadata, not transport-layer state.

Takeaway

The internet stack works in layers: DNS resolves names to IPs, TCP guarantees byte delivery between two endpoints, UDP offers lighter datagram semantics at the cost of reliability, and HTTP builds a structured protocol on top of TCP’s pipe. Java gives you direct access to each layer through InetAddress, Socket/DatagramSocket, and HttpClient — no proxy object between you and the protocol.