JDK HttpClient with Virtual Threads and WebClient: The Future of Asynchronous HTTP in Java
Introduction: Java’s HTTP Revolution
The landscape of HTTP communication in Java has undergone a dramatic transformation. Gone are the days when developers relied solely on Apache HttpClient or OkHttp for modern HTTP needs. With Java 11’s introduction of the standardized HttpClient API and Java 21‘s groundbreaking virtual threads (Project Loom), Java now offers a first-class, high-performance solution for HTTP communication that rivals any third-party library.
When you combine the modern JDK HttpClient with Spring’s WebClient and leverage virtual threads, you unlock unprecedented efficiency in building reactive, scalable applications. This isn’t just an incremental improvement—it’s a fundamental shift in how Java handles concurrent HTTP operations.
Understanding the Building Blocks
JDK HttpClient: Modern by Design
Introduced as a standard feature in Java 11, the java.net.http.HttpClient was built from the ground up for modern application needs. Unlike the legacy HttpURLConnection, this new client supports HTTP/2 out of the box, handles connection pooling automatically, provides both synchronous and asynchronous APIs, and integrates seamlessly with Java’s reactive streams.
The API is clean, fluent, and intuitive:
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users"))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
Virtual Threads: Concurrency Without the Cost
Virtual threads, the flagship feature of Java 21’s Project Loom, fundamentally change the economics of thread-based concurrency. Traditional platform threads are expensive—each one consumes significant memory (typically 1-2 MB) and requires OS-level context switching. This limited applications to thousands of threads at most.
Virtual threads are lightweight, user-mode threads managed by the JVM rather than the operating system. You can create millions of them without breaking a sweat. They’re perfect for I/O-bound operations like HTTP calls, where threads spend most of their time waiting.
// Create millions of virtual threads effortlessly
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 1_000_000).forEach(i -> {
executor.submit(() -> {
// Make HTTP call
return fetchUserData(i);
});
});
}
Spring WebClient: Reactive HTTP for Spring
Spring’s WebClient provides a modern, fluent API for making HTTP requests in reactive applications. It’s non-blocking, supports reactive streams, and integrates beautifully with Spring’s ecosystem. WebClient replaced the older RestTemplate as the recommended approach for HTTP communication in Spring applications.
WebClient client = WebClient.builder()
.baseUrl("https://api.example.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
Mono<User> user = client.get()
.uri("/users/{id}", userId)
.retrieve()
.bodyToMono(User.class);
The Convergence: JDK HttpClient Meets Virtual Threads
The magic happens when you integrate JDK HttpClient with virtual threads. This combination gives you the simplicity of synchronous code with the scalability of asynchronous operations.
Building a Virtual Thread-Powered HTTP Client
Here’s a practical implementation that leverages virtual threads for concurrent HTTP requests:
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URI;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class VirtualThreadHttpClient {
private final HttpClient httpClient;
public VirtualThreadHttpClient() {
this.httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
}
public List<String> fetchMultipleUrls(List<String> urls) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = urls.stream()
.map(url -> executor.submit(() -> fetchUrl(url)))
.toList();
return futures.stream()
.map(future -> {
try {
return future.get();
} catch (Exception e) {
return "Error: " + e.getMessage();
}
})
.toList();
}
}
private String fetchUrl(String url) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
return response.body();
} catch (Exception e) {
throw new RuntimeException("Failed to fetch: " + url, e);
}
}
}
Notice the key detail: we configure the HttpClient with a virtual thread executor. This means every HTTP request runs on a virtual thread, allowing massive concurrency without resource exhaustion.
Performance Implications
The performance gains are substantial. In traditional thread-per-request models, you might max out at a few thousand concurrent requests before running into memory or CPU constraints. With virtual threads, that limit effectively disappears.
A benchmark comparing approaches for 10,000 concurrent HTTP requests:
- Traditional platform threads: 8-10 seconds, 2-3 GB memory
- Reactive WebFlux: 3-4 seconds, 500 MB memory
- Virtual threads + HttpClient: 3-4 seconds, 300 MB memory
Virtual threads achieve reactive-level performance with imperative, easier-to-read code.
Integrating with Spring WebClient
You can enhance Spring WebClient to use virtual threads as well, combining Spring’s powerful ecosystem with virtual thread scalability.
Custom WebClient Configuration
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.reactive.function.client.WebClient;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.concurrent.Executors;
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
HttpClient httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
JdkClientHttpRequestFactory requestFactory =
new JdkClientHttpRequestFactory(httpClient);
return WebClient.builder()
.clientConnector(new HttpComponentsClientHttpConnector(requestFactory))
.baseUrl("https://api.example.com")
.defaultHeader("User-Agent", "Spring-VirtualThread-Client")
.build();
}
}
Practical Service Implementation
Here’s a real-world service that leverages this configuration:
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.concurrent.Executors;
@Service
public class UserService {
private final WebClient webClient;
public UserService(WebClient webClient) {
this.webClient = webClient;
}
public Mono<User> getUserById(Long userId) {
return webClient.get()
.uri("/users/{id}", userId)
.retrieve()
.bodyToMono(User.class)
.doOnError(error ->
System.err.println("Error fetching user: " + error.getMessage()));
}
public Flux<User> getAllUsers() {
return webClient.get()
.uri("/users")
.retrieve()
.bodyToFlux(User.class);
}
// Virtual thread-based batch processing
public List<User> getUsersBatch(List<Long> userIds) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = userIds.stream()
.map(id -> executor.submit(() ->
getUserById(id).block()))
.toList();
return futures.stream()
.map(future -> {
try {
return future.get();
} catch (Exception e) {
return null;
}
})
.filter(user -> user != null)
.toList();
}
}
}
Advanced Patterns and Best Practices
Connection Pooling and Resource Management
The JDK HttpClient manages connection pooling automatically, but you should still be mindful of resource limits:
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.executor(Executors.newVirtualThreadPerTaskExecutor())
// HTTP/2 multiplexing handles multiple requests per connection
.build();
HTTP/2’s multiplexing capability means multiple requests can share a single TCP connection, dramatically reducing overhead compared to HTTP/1.1.
Error Handling and Resilience
Building resilient HTTP clients requires proper error handling and retry logic:
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
public class ResilientHttpClient {
private final HttpClient httpClient;
private final int maxRetries = 3;
public String fetchWithRetry(String url) {
int attempt = 0;
Exception lastException = null;
while (attempt < maxRetries) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 200 && response.statusCode() < 300) {
return response.body();
} else if (response.statusCode() >= 500) {
// Retry on server errors
Thread.sleep(1000 * (attempt + 1)); // Exponential backoff
attempt++;
continue;
} else {
throw new RuntimeException("HTTP " + response.statusCode());
}
} catch (HttpTimeoutException e) {
lastException = e;
attempt++;
System.out.println("Timeout on attempt " + attempt);
} catch (Exception e) {
throw new RuntimeException("Request failed", e);
}
}
throw new RuntimeException("Max retries exceeded", lastException);
}
}
Structured Concurrency
Java 21 also introduced structured concurrency, which pairs perfectly with virtual threads for managing complex concurrent operations:
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
public class StructuredHttpFetcher {
public AggregatedData fetchAllData(String userId) {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> userTask = scope.fork(() -> fetchUser(userId));
Subtask<List<Order>> ordersTask = scope.fork(() -> fetchOrders(userId));
Subtask<Profile> profileTask = scope.fork(() -> fetchProfile(userId));
scope.join(); // Wait for all tasks
scope.throwIfFailed(); // Propagate errors
return new AggregatedData(
userTask.get(),
ordersTask.get(),
profileTask.get()
);
} catch (Exception e) {
throw new RuntimeException("Failed to fetch data", e);
}
}
}
This pattern ensures all subtasks complete or fail together, with automatic cleanup and error propagation.
Real-World Use Cases
Microservices Communication
In microservice architectures, services constantly communicate over HTTP. Virtual threads eliminate the traditional trade-off between simplicity (thread-per-request) and scalability:
@RestController
@RequestMapping("/api")
public class OrderController {
private final HttpClient httpClient;
public OrderController() {
this.httpClient = HttpClient.newBuilder()
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
}
@GetMapping("/orders/{orderId}/details")
public OrderDetails getOrderDetails(@PathVariable String orderId) {
// These calls run concurrently on virtual threads
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var orderTask = scope.fork(() -> fetchOrder(orderId));
var userTask = scope.fork(() -> fetchUser(orderTask.get().getUserId()));
var inventoryTask = scope.fork(() -> fetchInventory(orderId));
var shippingTask = scope.fork(() -> fetchShipping(orderId));
scope.join();
scope.throwIfFailed();
return new OrderDetails(
orderTask.get(),
userTask.get(),
inventoryTask.get(),
shippingTask.get()
);
} catch (Exception e) {
throw new RuntimeException("Failed to build order details", e);
}
}
}
API Gateway Aggregation
API gateways often need to aggregate data from multiple backend services. Virtual threads make this pattern efficient and straightforward:
public class ApiGateway {
private final HttpClient httpClient;
public DashboardData getDashboard(String userId) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = List.of(
executor.submit(() -> fetchService("user-service", userId)),
executor.submit(() -> fetchService("notification-service", userId)),
executor.submit(() -> fetchService("analytics-service", userId)),
executor.submit(() -> fetchService("recommendation-service", userId))
);
List<String> results = futures.stream()
.map(future -> {
try {
return future.get(Duration.ofSeconds(2));
} catch (Exception e) {
return "{}"; // Fallback to empty data
}
})
.toList();
return aggregateResults(results);
}
}
}
Performance Tuning and Monitoring
Metrics Collection
Monitor your HTTP client performance to identify bottlenecks:
import java.util.concurrent.atomic.LongAdder;
public class MonitoredHttpClient {
private final HttpClient httpClient;
private final LongAdder successCount = new LongAdder();
private final LongAdder errorCount = new LongAdder();
private final LongAdder totalLatency = new LongAdder();
public HttpResponse<String> fetchWithMetrics(String url) {
long startTime = System.currentTimeMillis();
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.build();
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
successCount.increment();
return response;
} catch (Exception e) {
errorCount.increment();
throw new RuntimeException(e);
} finally {
long latency = System.currentTimeMillis() - startTime;
totalLatency.add(latency);
}
}
public Stats getStats() {
long total = successCount.sum() + errorCount.sum();
return new Stats(
successCount.sum(),
errorCount.sum(),
total > 0 ? totalLatency.sum() / total : 0
);
}
}
JVM Configuration
Enable virtual threads and optimize JVM settings for HTTP workloads:
# Run with Java 21+
java -XX:+UseZGC \
-XX:+UnlockExperimentalVMOptions \
-XX:+UseZGC \
-Xms512m -Xmx2g \
-Djdk.tracePinnedThreads=full \
-jar your-application.jar
The jdk.tracePinnedThreads flag helps identify situations where virtual threads get “pinned” to platform threads, which can impact performance.
Migration Strategies
From RestTemplate to WebClient with Virtual Threads
If you’re migrating from Spring’s legacy RestTemplate:
// Old approach with RestTemplate
@Service
public class OldUserService {
private final RestTemplate restTemplate;
public User getUser(Long id) {
return restTemplate.getForObject(
"https://api.example.com/users/{id}",
User.class,
id
);
}
}
// New approach with WebClient and virtual threads
@Service
public class NewUserService {
private final WebClient webClient;
public User getUser(Long id) {
return webClient.get()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(User.class)
.block(); // Safe with virtual threads!
}
}
The key insight: with virtual threads, blocking operations like .block() are no longer expensive, giving you the simplicity of synchronous code without sacrificing scalability.
Common Pitfalls and Solutions
Pinned Virtual Threads
Virtual threads can get “pinned” to platform threads in certain situations, negating their benefits:
Problem: Synchronized blocks pin virtual threads
// Avoid this
synchronized(lock) {
makeHttpCall(); // Pins the virtual thread!
}
Solution: Use ReentrantLock instead
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
makeHttpCall(); // Virtual thread remains unpinned
} finally {
lock.unlock();
}
Connection Limits
Even with virtual threads, you’re still bound by network and server limits:
// Configure sensible timeouts
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
// Add circuit breaker pattern for external services
CircuitBreaker breaker = CircuitBreaker.ofDefaults("externalAPI");
String result = breaker.executeSupplier(() -> fetchExternalApi());
The Future: What’s Next?
The convergence of JDK HttpClient, virtual threads, and reactive frameworks represents a maturation of Java’s approach to concurrent HTTP communication. Upcoming enhancements in future Java versions may include:
- Further structured concurrency improvements
- Enhanced HTTP/3 support with QUIC protocol
- Better integration between virtual threads and reactive streams
- Improved observability and debugging tools
Conclusion
The combination of JDK HttpClient, virtual threads, and WebClient gives Java developers a powerful, modern toolkit for building highly concurrent HTTP applications. You get the simplicity of synchronous, imperative code with the scalability traditionally reserved for complex reactive or async frameworks.
Virtual threads democratize high concurrency—you no longer need to be an expert in reactive programming to build scalable systems. The JDK HttpClient provides a robust, standardized HTTP client that eliminates the need for third-party dependencies in many cases.
Start experimenting with virtual threads in your HTTP-heavy applications. The migration path is straightforward, the performance benefits are real, and the code simplicity is refreshing. This is Java’s answer to modern, concurrent HTTP communication—and it’s production-ready today.
Essential Resources
Official Java HttpClient Documentation https://docs.oracle.com/en/java/javase/21/docs/api/java.net.http/java/net/http/HttpClient.html
Comprehensive documentation covering the JDK HttpClient API, including virtual thread integration, HTTP/2 support, best practices for async operations, and detailed examples. This resource also includes the official Project Loom documentation for virtual threads and structured concurrency patterns, essential for understanding modern concurrent Java applications.

