Java HttpClient Guide: Master REST APIs, WebSocket, and Async Calls in Java 11+

Learn how Java's built-in HttpClient simplifies REST calls, async requests, and WebSocket connections. Practical code examples included. Start building today.

Java HttpClient Guide: Master REST APIs, WebSocket, and Async Calls in Java 11+

I have written Java for many years, and for most of that time the built-in HTTP tools felt painful. Before Java 11, I had to use HttpURLConnection, which was clunky, or bring in external libraries for simple REST calls. Then the java.net.http.HttpClient came along. It changed how I write API integrations. It supports HTTP/2, WebSocket, synchronous and asynchronous calls, and it gives me a clean builder style for requests and responses. I want to walk you through the patterns I use all the time. If you are new to this, do not worry. I will explain everything in plain words with small code examples.

Think of HttpClient as a browser without a window. It can send requests, read responses, follow redirects, handle headers, and even keep a WebSocket connection open. You create one client, set the options you want, and then reuse it for all your calls. Creating a new client for every request wastes memory and sockets. I create one client at the start of my application and share it across the code.

The first thing I do when I need a client is configure it with a builder. I set a connection timeout, decide how redirects should work, choose the HTTP version, and sometimes provide an executor for background tasks.

HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(10))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .version(HttpClient.Version.HTTP_2)
        .executor(Executors.newFixedThreadPool(8))
        .build();

The connection timeout is the time limit for establishing a connection. It is not the time limit for the whole request. The redirect setting tells the client when to follow a redirect. NORMAL means it will follow redirects for GET requests but not for HTTPS to HTTP changes. The HTTP version tells the client to try HTTP/2 first. If the server does not support HTTP/2, it falls back to HTTP/1.1. The executor is a small team of worker threads. When a request is asynchronous, it runs on one of those threads. If I do not provide an executor, the client uses a common pool that other parts of the JDK also use. I prefer a small fixed pool when I deal with slow APIs.

One important habit: reuse the same client. A client holds connection pools and other internal resources. If you create a new client for each request, you lose those benefits. In a small application, one client is enough. In a larger application, you can have a different client for different services.

Now let me show you the simplest request. This is a synchronous GET call. The program sends the request and waits until the response arrives. It is like calling someone on the phone and waiting for them to answer. You do not do anything else until they pick up.

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.example.com/users"))
        .header("Accept", "application/json")
        .timeout(Duration.ofSeconds(5))
        .GET()
        .build();

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

BodyHandlers.ofString() is a recipe that says: read the entire response body as text. The HttpResponse object gives me the status code, headers, and body. I always check the status code before using the body. A request can complete successfully at the network level, but the API can return a 404 or a 500. The body may be an error message.

if (response.statusCode() == 200) {
    System.out.println(response.body());
} else {
    System.out.println("Request failed with status " + response.statusCode());
}

The send method throws IOException when the connection fails, and InterruptedException when the thread is interrupted. In a real program, you need to handle those exceptions. I usually wrap the call in a try-catch and let the caller know what happened.

When you need to send data to an API, you POST a body. The body can be JSON, form data, XML, or bytes. For JSON, the simplest way is to put the JSON in a string and use BodyPublishers.ofString.

String json = """
        {
          "name": "Alice",
          "email": "[email protected]"
        }
        """;

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.example.com/users"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(json))
        .build();

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

Notice that I set the Content-Type header myself. The body publisher does not do that for me. If I forget it, the server may not understand the request. Some APIs also need an Accept header so the server knows what response format you want. I get into the habit of setting both headers when I work with REST APIs.

The response from a POST is usually the created object, sometimes with an ID. I read it the same way I read a GET response. If the status code is 201 Created, I know the resource was made. If it is 400 Bad Request, the JSON I sent may have been invalid. The server response body often contains details about the error.

HttpResponse has more than just a body. It also gives me the status code, the final URI, and the response headers. Headers are name-value pairs. Some headers appear once. Others, like Set-Cookie, can appear multiple times.

int status = response.statusCode();
Optional<String> contentType = response.headers().firstValue("Content-Type");
List<String> cookies = response.headers().allValues("Set-Cookie");

The firstValue method returns an Optional because the header may not be present. The allValues method returns a list of all values for that header. This is useful for reading multiple cookies or multiple warning headers. I use this when I need to debug an API that sends back interesting headers.

A simple check on the status code can drive your error handling. If I see a 404, I know the resource is missing. If I see a 401 or 403, I know there is an authentication problem. If I see a 429, the server wants me to slow down. If I see 500 or 502, something went wrong on the server side.

Synchronous calls are easy to understand, but they block your thread. If your application needs to call ten APIs at the same time, you do not want to wait for each one one after another. That is where sendAsync comes in. It returns a CompletableFuture, which is a promise that will have the result later.

CompletableFuture<HttpResponse<String>> future =
        client.sendAsync(request, HttpResponse.BodyHandlers.ofString());

future.thenApply(HttpResponse::body)
      .thenAccept(body -> System.out.println("Response: " + body))
      .exceptionally(ex -> {
          System.err.println("Request failed: " + ex.getMessage());
          return null;
      });

The call starts in the background. The thenApply step takes the response and turns it into the body. The thenAccept step uses the body. The exceptionally step handles any error that happens along the way. This is a reactive style of programming. Your code continues running while the HTTP request happens on another thread.

If you need to wait for the result in a regular method, you can call join(). But you should add a timeout so you do not wait forever. The orTimeout method is perfect for that.

HttpResponse<String> response = future
        .orTimeout(10, TimeUnit.SECONDS)
        .join();

If the future does not complete within ten seconds, it times out and join throws an exception. I use this pattern when my program cannot move forward until it has the API response, but I still want to use the asynchronous client to keep the code organized.

Uploading a large file is a different problem. If I read the entire file into a string or byte array, I can use too much memory. The solution is to stream the request body. BodyPublishers.ofInputStream accepts a supplier that returns an input stream. The HTTP client opens the stream, reads from it while sending the request, and closes it when done.

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.example.com/upload"))
        .header("Content-Type", "application/octet-stream")
        .POST(HttpRequest.BodyPublishers.ofInputStream(
                () -> new FileInputStream("/tmp/backup.zip")))
        .build();

HttpResponse<Void> response = client.send(request,
        HttpResponse.BodyHandlers.discarding());

I use BodyHandlers.discarding() when I only care about the status code and not the response body. The stream is sent from disk, not from memory. This pattern works well for files that are hundreds of megabytes. If you need to send progress information, you can wrap the stream in a custom FilterInputStream that reports how many bytes have been read.

Timeouts are important. Without them, your program can wait on a dead server for a very long time. The HttpClient has a connection timeout, but you also need a per-request timeout. The request timeout covers the entire exchange: sending the request, waiting for the server, and receiving the response.

HttpRequest request = HttpRequest.newBuilder(uri)
        .timeout(Duration.ofSeconds(3))
        .GET()
        .build();

try {
    HttpResponse<String> response = client.send(request,
            HttpResponse.BodyHandlers.ofString());
} catch (HttpTimeoutException e) {
    System.out.println("The request timed out.");
} catch (IOException e) {
    System.out.println("Network error: " + e.getMessage());
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

I like to set a timeout on every request. Even when the API is normally fast, a slow response can be worse than no response. A timeout tells the client to stop waiting and let me handle the situation. For asynchronous calls, orTimeout works the same way. It gives me a limit on how long I am willing to wait for the future.

Retries are another useful pattern. Transient errors happen. A server can return 503 Service Unavailable for a few seconds while it restarts. A connection can reset. When that happens, I do not want to crash immediately. I retry a few times, and I wait longer between each attempt. This is called exponential backoff.

private HttpResponse<String> sendWithRetry(
        HttpClient client,
        HttpRequest request) throws Exception {

    int maxAttempts = 3;
    Duration delay = Duration.ofSeconds(1);
    HttpResponse<String> response = null;

    for (int attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
            response = client.send(request,
                    HttpResponse.BodyHandlers.ofString());

            if (response.statusCode() < 500) {
                return response;
            }
        } catch (IOException e) {
            if (attempt == maxAttempts) {
                throw e;
            }
        }

        System.out.println("Attempt " + attempt + " failed. Waiting " +
                delay.toMillis() + " ms");

        Thread.sleep(delay.toMillis());
        delay = delay.multipliedBy(2);
    }

    return response;
}

I do not retry every error. A 400 Bad Request means my request is wrong. Retrying it will not help. A 404 Not Found means the resource is missing. Retrying usually will not help either. I only retry connection failures, timeouts, and server error codes from 500 to 599. I also limit the number of attempts. Three is a good number. After that, I let the exception go up.

The sleep call is a simple way to wait. In a normal Java thread, sleeping can waste a thread. If you use virtual threads, sleeping is fine. If you use platform threads, try to move the retry logic to a separate executor or scheduler. The idea is the same: do not hammer the server.

Authentication is part of almost every API. The Authorization header carries your credentials. There are two common styles. Basic authentication uses a username and password. The client encodes them with Base64 and puts them after the word Basic. Bearer authentication uses a token with the word Bearer.

String username = System.getenv("API_USER");
String password = System.getenv("API_PASS");
String token = System.getenv("API_TOKEN");

String basic = "Basic " + Base64.getEncoder()
        .encodeToString((username + ":" + password)
        .getBytes(StandardCharsets.UTF_8));

HttpRequest basicRequest = HttpRequest.newBuilder(uri)
        .header("Authorization", basic)
        .build();

HttpRequest bearerRequest = HttpRequest.newBuilder(uri)
        .header("Authorization", "Bearer " + token)
        .build();

You should never put credentials in source code. I read them from environment variables or from a configuration service. The Authorization header is just a header. Once the request is built, you can reuse it until the token expires. When a token expires, you need to refresh it and build a new request.

WebSocket is the last big feature of the Java HTTP client. A WebSocket is a persistent connection between the client and the server. The server can push messages at any time. This is useful for chat apps, live sports scores, and dashboard updates. The client builder has a newWebSocketBuilder method.

WebSocket webSocket = client.newWebSocketBuilder()
        .connectTimeout(Duration.ofSeconds(10))
        .buildAsync(URI.create("wss://example.com/socket"),
                new WebSocket.Listener() {

                    @Override
                    public void onOpen(WebSocket webSocket) {
                        System.out.println("Connected");
                        webSocket.request(1);
                        webSocket.sendText("hello", true);
                    }

                    @Override
                    public CompletionStage<?> onText(
                            WebSocket webSocket,
                            CharSequence data,
                            boolean last) {
                        System.out.println("Message: " + data);
                        webSocket.request(1);
                        return null;
                    }

                    @Override
                    public CompletionStage<?> onClose(
                            WebSocket webSocket,
                            int statusCode,
                            String reason) {
                        System.out.println("Closed: " + statusCode);
                        return null;
                    }
                })
        .join();

The request(1) call is important. It tells the WebSocket implementation that I am ready to receive another message. If I never call request, I will only see the first message or none at all. Each time I finish processing a message, I call request(1) again. This is called flow control. It prevents the server from sending more data than the client can handle.

Now let me talk about testing. I like to test my HTTP client logic without hitting a real API. The JDK includes a small HTTP server called com.sun.net.httpserver.HttpServer. I can start it on a random port, define a handler, and point my client at it. This gives me deterministic tests for status codes, headers, and response bodies.

HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);

server.createContext("/ping", exchange -> {
    byte[] body = "pong".getBytes(StandardCharsets.UTF_8);
    exchange.getResponseHeaders().set("Content-Type", "text/plain");
    exchange.sendResponseHeaders(200, body.length);
    try (OutputStream os = exchange.getResponseBody()) {
        os.write(body);
    }
});

server.start();

try {
    String url = "http://localhost:" + server.getAddress().getPort() + "/ping";
    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder(URI.create(url)).build();
    HttpResponse<String> response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

    if (response.statusCode() != 200) {
        throw new AssertionError("Unexpected status " + response.statusCode());
    }

    if (!"pong".equals(response.body())) {
        throw new AssertionError("Unexpected body " + response.body());
    }

    System.out.println("Test passed");
} finally {
    server.stop(0);
}

Using port 0 means the operating system chooses a free port. This avoids conflicts with other tests. I can create handlers that return 500, or handlers that sleep before responding, or handlers that send malformed JSON. That lets me test my timeout and retry logic too. The built-in server is not a production web server. It is a test tool. For unit tests, it is excellent.

I use these patterns every day. For a simple REST call, I use a synchronous request. For parallel calls, I use sendAsync. For file uploads, I stream. For real-time push messages, I use WebSocket. Each pattern is separate, so you can start with one and add the others when you need them.

The best part is that all of this comes with the JDK. No extra dependencies. No complicated setup. If you are on Java 11 or newer, you already have everything you need. I still remember the first time I replaced HttpURLConnection with HttpClient. My code got shorter, easier to read, and more reliable. If you are still using the old API, I hope these patterns help you make the switch. If you are just learning, start with the synchronous GET request, then try a POST. After that, explore async and WebSocket at your own speed.

The Java HTTP client is not just a utility. It is the foundation for building modern integrations. With a few simple builder methods, you can talk to REST APIs, upload files, handle retries, and maintain WebSocket connections. I have no doubt that once you get comfortable with these patterns, you will not want to go back.


// Keep Reading

Similar Articles