What is the difference between HttpClient.send() and sendAsync(), and when would you use each?
answer
- send() blocks → returns HttpResponse, throws IOException/InterruptedException
- sendAsync() returns CompletableFuture immediately
- Async runs on the client's Executor
- Async errors complete the future exceptionally (not thrown)
- Async wins for fan-out concurrency; virtual threads narrow the gap
basics
~10 ssend() is synchronous: it blocks the calling thread until the response arrives and returns the HttpResponse directly. sendAsync() is non-blocking: it returns immediately with a CompletableFuture that completes later with the response.
solid answer
~40 ssend() runs the request on the calling thread and blocks until the full response is available, returning HttpResponse<T> and throwing IOException/InterruptedException. It's simplest for sequential code or when you already have a thread to spare. sendAsync() returns a CompletableFuture<HttpResponse<T>> immediately; the request executes on the client's Executor and the future completes when done. You compose continuations with thenApply/thenCompose/exceptionally, and you can fire many requests concurrently without one thread per request. Async failures don't throw at the call site — they complete the future exceptionally, so you handle them via exceptionally/handle. Use send() for straightforward, blocking flows; use sendAsync() for high concurrency, fan-out/fan-in, or when you must not block (event loops, reactive pipelines). Both share the same HttpClient configuration and connection pool. There's also a variant of sendAsync taking a PushPromiseHandler for HTTP/2 server push.
go deeper
Knows send() blocks and returns the response while sendAsync() returns a future to complete later.
Explains the CompletableFuture return, that async runs on the executor, and that failures complete exceptionally; can write a thenApply/exceptionally chain.
Reasons about concurrency models (thread-per-request vs fan-out), executor configuration, and shared connection pooling; chooses the right mode per workload.
Weighs sendAsync vs blocking-on-virtual-threads for a platform, considers backpressure, executor sizing, and observability of async failure paths across services.
## Background: blocking vs non-blocking When you make a network call, the response takes time to arrive (DNS, TCP/TLS handshake, server processing, transfer). A **blocking** (synchronous) call makes the current thread *wait* — it does nothing else until the answer comes back. A **non-blocking** (asynchronous) call returns *immediately* with a placeholder for the eventual result, freeing the thread to do other work; the result is delivered later. ## `send()` — synchronous ```java HttpResponse<String> resp = client.send(request, HttpResponse.BodyHandlers.ofString()); ``` - Executes on **the calling thread**, which **blocks** until the entire response (status, headers, and the body as shaped by the handler) is ready. - Returns the `HttpResponse<T>` directly. - Throws **checked** `IOException` (network/protocol problems) and `InterruptedException` (the thread was interrupted while waiting). - A non-2xx status (404, 500) is a normal return, not an exception. Use it when the code is naturally sequential, you have a thread you can afford to block (e.g. a worker thread, a CLI, a request-per-thread server), and simplicity matters. ## `sendAsync()` — asynchronous ```java CompletableFuture<HttpResponse<String>> future = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()); ``` - Returns **immediately** with a **`CompletableFuture<HttpResponse<T>>`** — a handle to a result that will exist later. - The actual I/O runs on the client's **`Executor`** (a thread pool you can supply via `HttpClient.newBuilder().executor(...)`; otherwise a default pool is used). - You build a pipeline of continuations: - `thenApply(fn)` — transform the result (e.g. extract `.body()`), - `thenCompose(fn)` — chain another async call (flatMap), - `exceptionally(fn)` / `handle(fn)` — recover from failures, - `thenAccept`, `whenComplete`, etc. - **Failures do not throw at the call site.** A network error completes the future *exceptionally*; you observe it through `exceptionally`/`handle`/`get()`. Forgetting to attach error handling silently swallows failures. ```java client.sendAsync(request, BodyHandlers.ofString()) .thenApply(HttpResponse::body) .thenAccept(System.out::println) .exceptionally(ex -> { ex.printStackTrace(); return null; }); ``` ## Concurrency: the real reason async exists With `send()`, running 100 requests in parallel needs 100 threads (each blocked waiting). With `sendAsync()`, you fire 100 futures and let the client multiplex them on a small pool — ideal for **fan-out/fan-in**: ```java List<CompletableFuture<String>> calls = urls.stream() .map(u -> client.sendAsync(reqFor(u), BodyHandlers.ofString()) .thenApply(HttpResponse::body)) .toList(); CompletableFuture.allOf(calls.toArray(CompletableFuture[]::new)).join(); ``` ## How to choose - **`send()`**: sequential logic, blocking is acceptable, simplest code, or you're already on a virtual thread (Project Loom) where blocking is cheap. - **`sendAsync()`**: high concurrency, you must not block the current thread (UI/event loops, reactive systems), or you're composing multiple dependent/parallel calls. Both share the same `HttpClient`, so connection pooling, HTTP version, and redirect policy are identical. `sendAsync` additionally has an overload taking a `PushPromiseHandler` to receive HTTP/2 server-pushed resources. ## Note on virtual threads Since Java 21, **virtual threads** make blocking `send()` calls cheap (a blocked virtual thread doesn't tie up an OS thread). This narrows the gap: you can often write simple blocking `send()` code and still get high concurrency by running each on a virtual thread, instead of reaching for `sendAsync()`.
- Where does the async request actually execute?On the HttpClient's Executor — a thread pool you can configure via newBuilder().executor(...); if you don't, a sensible default pool is used.
- How do you handle errors from sendAsync()?Attach exceptionally(fn) or handle(result, throwable) to the returned CompletableFuture; the failure arrives as the future completing exceptionally, not as a thrown exception.
- How do virtual threads change the send vs sendAsync decision?Virtual threads (Java 21+) make blocking send() cheap, so simple blocking code on virtual threads can achieve high concurrency without the complexity of CompletableFuture chains.
saying these in an interview costs you the question
- Claiming sendAsync() throws IOException at the call site (it completes the future exceptionally instead)
- Forgetting to attach exceptionally/handle, silently swallowing async failures
- Spawning one thread per send() for high concurrency instead of using sendAsync()
- Thinking the two use different connection pools — they share the same HttpClient