Skip to content
ArticlesBack EndNetworking

Modern Java 25 HttpClient and Virtual Threads: High-Throughput Networking

15 min readsalahtobok
Modern Java 25 HttpClient and Virtual Threads: High-Throughput Asynchronous Networking Without Reactive Complexity

The Concurrency Dilemma in Java Networking

In distributed enterprise architectures, microservices spend the vast majority of their operational lives waiting for network input and output. When an API gateway receives a customer request, it rarely performs heavy mathematical calculations on the local CPU. Instead, it issues multiple HTTP calls to downstream services: querying an authentication provider, fetching user account details, retrieving catalog pricing, and querying inventory databases.

For more than twenty years, Java developers built these systems using the classic thread-per-request model. In this model, every incoming HTTP connection is assigned a dedicated Java thread. That thread executes synchronous, blocking code: opening sockets, writing HTTP headers, and waiting for responses. The programming model was simple, intuitive, and easy to debug. Standard try-catch blocks handled exceptions, stack traces clearly showed where errors occurred, and step-debugging in IDEs worked flawlessly.

However, the thread-per-request model had a crippling limitation: in traditional Java, every Java thread was a 1:1 wrapper around an operating system kernel thread. OS threads are heavy operating system constructs. Each thread reserves approximately 1 megabyte of memory for its execution stack. If a server spawns 3,000 platform threads, it consumes 3 gigabytes of RAM purely for thread stacks, even before processing any business data.

More critically, when a platform thread invokes a blocking socket read (such as waiting for a downstream microservice that takes 200 milliseconds to respond), the operating system puts that thread into a sleep state. The thread cannot do any useful work, yet its memory remains locked. If traffic surges and 5,000 concurrent requests arrive, thread pools quickly saturate, leading to thread exhaustion, massive latency spikes, and cascading HTTP 503 errors.

How Virtual Threads Work Under the Hood

To resolve this scalability bottleneck, OpenJDK introduced Virtual Threads under Project Loom (finalized in Java 21 and enhanced in Java 25 under JEP 444). Virtual Threads fundamentally decouple the Java threading model from the operating system kernel.

Concurrency Paradigms: OS Platform Threads vs Reactive Streams vs Java 25 Virtual Threads
Figure 1: Comparing memory footprint, execution models, and debugging friction across concurrency paradigms.

A virtual thread is an extremely lightweight implementation of java.lang.Thread managed entirely by the Java Virtual Machine runtime rather than the host operating system. Instead of allocating a 1MB native operating system stack, a virtual thread begins with a tiny stack frame allocated on the Java heap—typically consuming only around 1 kilobyte of memory.

Because virtual threads are so lightweight, a single JVM can easily host millions of concurrent virtual threads simultaneously. Developers no longer need to pool threads or ration concurrency. When a new task arrives, the application simply creates a brand-new virtual thread, executes the work, and lets the garbage collector reclaim it when finished.

The Modern Java HttpClient Architecture

In Java 11, the JDK introduced the modern java.net.http.HttpClient (standardized in RFC 9113 for HTTP/2 and RFC 8446 for TLS 1.3), completely replacing the obsolete HttpURLConnection. Modern HttpClient was designed from the ground up to support asynchronous, non-blocking networking.

The beauty of combining HttpClient with Java 25 Virtual Threads is that it eliminates the historic tradeoff between simplicity and scalability. In the past, achieving high-throughput asynchronous networking required complex non-blocking frameworks like Netty or Spring WebFlux. Developers were forced to use reactive streams, monadic composition (Mono and Flux), and complex callback pipelines.

With Java 25, you configure HttpClient to use a virtual thread per task executor:

HttpClient client = HttpClient.newBuilder()
    .version(HttpClient.Version.HTTP_2)
    .connectTimeout(Duration.ofSeconds(5))
    .executor(Executors.newVirtualThreadPerTaskExecutor())
    .build();

Now, whenever your application calls the simple, blocking client.send(request, BodyHandlers.ofString()) method inside a virtual thread, the code appears completely synchronous. However, behind the scenes, the JVM executes non-blocking I/O with line-rate efficiency.

Carrier Thread Parking and Unmounting Mechanics

How does the JVM achieve non-blocking scalability while allowing developers to write straightforward blocking code? The secret lies in the collaboration between the JVM’s ForkJoinPool scheduler and low-level operating system I/O multiplexers (such as epoll on Linux or kqueue on macOS).

Virtual Thread Lifecycle: Non-Blocking Socket I/O and Carrier Thread Parking
Figure 2: Carrier thread unmounting flow: parking virtual threads on heap memory during network I/O wait.

When a virtual thread executes on the CPU, it is temporarily mounted onto an underlying operating system platform thread called a carrier thread. The JVM maintains a small, fixed-size pool of carrier threads—usually equal to the number of available physical CPU cores (e.g., 16 carrier threads on a 16-core server).

The execution lifecycle proceeds through three distinct phases:

  1. Execution Phase: Virtual Thread #1 is mounted on Carrier Thread #3. It runs normal application code and invokes httpClient.send(req, handler). The underlying socket transmits the HTTP request bytes over the network.
  2. Unmounting Phase: When the socket reaches a point where it must wait for incoming response packets from the remote server, the standard library networking code triggers a special JVM internal yield point. The JVM automatically unmounts Virtual Thread #1 from Carrier Thread #3. The virtual thread’s stack frames are copied to the Java heap. Carrier Thread #3 is instantly freed and immediately picks up Virtual Thread #2 from the ForkJoinPool queue to execute real CPU work!
  3. Resumption Phase: When the remote server responds, the operating system kernel notifies the JVM’s background network poller thread via epoll. The poller thread submits Virtual Thread #1 back to the ForkJoinPool. The first available carrier thread mounts Virtual Thread #1 and resumes execution right where it paused.

To the developer, the code was a clean, single-line blocking method call: HttpResponse<String> response = client.send(request, BodyHandlers.ofString());. In reality, zero operating system threads sat idle waiting for the network.

Scatter-Gather Fan-Out with Virtual Threads

One of the most common architectural patterns in modern API gateways is the Scatter-Gather Fan-Out. A single user request requires aggregating data from four independent backend microservices: User Profile, Order History, Recommendation Engine, and Loyalty Points.

Scatter-Gather Fan-Out Architecture: Structured Concurrency Microservice Calls
Figure 3: Parallel scatter-gather microservice aggregation with virtual threads and bounded timeouts.

In traditional synchronous programming, making four sequential HTTP calls each taking 50ms results in a total response time of 200ms (50ms + 50ms + 50ms + 50ms). In an asynchronous fan-out architecture, all four calls execute in parallel. The total response time equals the latency of the slowest service (approximately 50ms), reducing overall latency by 75%.

With Java 25 Virtual Threads, building a scatter-gather gateway requires no reactive libraries. You leverage standard Java concurrency primitives:

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<UserProfile> userFuture = executor.submit(() -> fetchUser(id));
    Future<OrderHistory> ordersFuture = executor.submit(() -> fetchOrders(id));
    Future<Recommendations> recoFuture = executor.submit(() -> fetchRecommendations(id));

    // Wait for all parallel tasks to complete
    UserProfile user = userFuture.get(2, TimeUnit.SECONDS);
    OrderHistory orders = ordersFuture.get(2, TimeUnit.SECONDS);
    Recommendations reco = recoFuture.get(2, TimeUnit.SECONDS);

    return new DashboardResponse(user, orders, reco);
}

Because each task runs in an isolated virtual thread, downstream latency has zero impact on the rest of the application. If the recommendation service experiences a delay, only its dedicated virtual thread waits; other requests continue executing at full speed.

Why Reactive Programming is Becoming Obsolete

Between 2015 and 2022, reactive programming frameworks (such as Project Reactor, RxJava, and Spring WebFlux) were heavily promoted as the only way to build high-throughput Java applications. Reactive programming solved the platform thread starvation problem by restructuring code into asynchronous event-driven pipelines.

However, reactive programming introduced immense complexity, commonly referred to as “function coloring”. Once an application adopts reactive streams, every layer of the architecture must return reactive types (Mono or Flux). Normal imperative programming patterns—such as for loops, standard try-catch blocks, and direct return statements—become unusable.

Furthermore, reactive systems suffer from critical operational drawbacks:

  • Unreadable Stack Traces: When an unhandled exception occurs in a reactive pipeline, the stack trace often spans hundreds of lines of internal Reactor scheduler frames, completely obscuring the original line of application code where the error occurred.
  • ThreadLocal Breakdown: In reactive event loops, different operators within the same pipeline execute on completely different worker threads. Traditional ThreadLocal variables (used for security contexts, database transaction tokens, and MDC logging correlation IDs) fail silently unless complex context-propagation bridges are implemented.
  • Impossible Step Debugging: Setting a breakpoint in an IDE and pressing F8 to step through code does not work in reactive pipelines. The debugger immediately jumps into the event loop scheduler rather than advancing to the next logical business step.

Java 25 Virtual Threads deliver the exact same high-throughput non-blocking scalability as reactive frameworks, but with standard, readable, synchronous Java code. For 98% of backend web applications, virtual threads render reactive programming completely obsolete.

Concurrency Paradigms Comparative Matrix

To help software architects choose the right concurrency strategy for their next-generation backend systems, the following matrix compares the three major paradigms:

Evaluation Dimension OS Platform Threads (Classic) Reactive Streams (WebFlux/Netty) Java 25 Virtual Threads (Loom)
Programming Style Imperative, synchronous, blocking Functional, asynchronous (Mono/Flux) Imperative, synchronous, blocking
Memory per Task ~1,024 KB (1 MB OS kernel stack) ~Few bytes (Lambda closures) ~1 KB (Dynamically sized on JVM heap)
Max Concurrency Limit 2,000 – 5,000 active threads 100,000+ active connections 1,000,000+ active virtual threads
Throughput Under I/O Latency Low (Crashes due to thread exhaustion) Extreme (Non-blocking event loop) Extreme (Identical to Reactive)
Stack Trace Readability Excellent (Direct call hierarchy) Fractured and illegible Excellent (Standard line numbers)
IDE Step Debugging Full F8 step-over support Broken (Jumps into event loop) Full F8 step-over support
ThreadLocal & Scoped Values Supported natively Broken across scheduler hops Supported natively (+ Scoped Values)
Ecosystem Compatibility Universal (JDBC, HTTP, Kafka) Requires reactive drivers (R2DBC) Universal (All existing Java libraries)
Networking Concurrency Scorecard: Throughput, Memory, and Code Complexity
Figure 4: Comprehensive enterprise scorecard evaluating platform threads, WebFlux, and Java 25 virtual threads.

Practical Java 25 HttpClient & Virtual Threads Implementation

To demonstrate production-grade asynchronous networking without external frameworks, we implemented an optimized scatter-gather gateway in pure Java 25. The project uses standard library HttpClient, immutable records, and virtual thread executors with zero Spring Boot dependencies.

First, we configure the ResilientHttpClientFactory:

package com.webcodein.workshop.httpclient.engine;

import java.net.http.HttpClient;
import java.time.Duration;
import java.util.concurrent.Executors;

public class ResilientHttpClientFactory {

    public static HttpClient createVirtualThreadHttpClient(Duration connectTimeout) {
        return HttpClient.newBuilder()
                .version(HttpClient.Version.HTTP_2)
                .connectTimeout(connectTimeout)
                .followRedirects(HttpClient.Redirect.NORMAL)
                .executor(Executors.newVirtualThreadPerTaskExecutor())
                .build();
    }
}

Second, we construct the ScatterGatherGateway, which fans out requests across multiple microservice endpoints concurrently:

package com.webcodein.workshop.httpclient.engine;

import com.webcodein.workshop.httpclient.model.AggregatedResponse;
import com.webcodein.workshop.httpclient.model.DownstreamTarget;
import com.webcodein.workshop.httpclient.model.GatewaySummary;

import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class ScatterGatherGateway {

    private final HttpClient httpClient;

    public ScatterGatherGateway(HttpClient httpClient) {
        this.httpClient = httpClient;
    }

    public GatewaySummary executeScatterGather(List<DownstreamTarget> targets) {
        long overallStart = System.currentTimeMillis();
        List<AggregatedResponse> results = new ArrayList<>();
        boolean allSucceeded = true;

        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<AggregatedResponse>> futures = new ArrayList<>();

            for (DownstreamTarget target : targets) {
                futures.add(executor.submit(() -> fetchTarget(target)));
            }

            for (Future<AggregatedResponse> future : futures) {
                try {
                    AggregatedResponse response = future.get();
                    results.add(response);
                    if (!response.success()) {
                        allSucceeded = false;
                    }
                } catch (InterruptedException | ExecutionException e) {
                    allSucceeded = false;
                    results.add(AggregatedResponse.failed("UNKNOWN", 500, e.getMessage(), 0));
                }
            }
        }

        long totalDurationMs = System.currentTimeMillis() - overallStart;
        return new GatewaySummary(results, totalDurationMs, allSucceeded);
    }

    private AggregatedResponse fetchTarget(DownstreamTarget target) {
        long start = System.currentTimeMillis();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(target.targetUri())
                .timeout(target.timeout())
                .header("Accept", "application/json")
                .GET()
                .build();

        try {
            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            long latencyMs = System.currentTimeMillis() - start;

            if (response.statusCode() >= 200 && response.statusCode() < 300) {
                return AggregatedResponse.ok(target.serviceName(), response.statusCode(), response.body(), latencyMs);
            } else {
                return AggregatedResponse.failed(target.serviceName(), response.statusCode(), response.body(), latencyMs);
            }
        } catch (IOException | InterruptedException e) {
            long latencyMs = System.currentTimeMillis() - start;
            return AggregatedResponse.failed(target.serviceName(), 504, e.getMessage(), latencyMs);
        }
    }
}

Real-World Enterprise Case Studies

Case Study 1: Global Flight Search Engine Migrates from WebFlux to Java 25 Virtual Threads

An international travel meta-search engine queries over 80 independent airline reservation systems for every customer flight search. In 2019, the company migrated its backend to Spring WebFlux and Project Reactor to handle high concurrent I/O. Over time, the engineering team suffered from extreme cognitive fatigue. Junior engineers struggled with reactive chaining, debugging production latency anomalies took days, and third-party relational database drivers required complex thread pool wrapping.

The team re-architected their aggregation engine to Java 25 Virtual Threads and HttpClient. They replaced thousands of lines of complex flatMap and Mono.zip code with clean, imperative ExecutorService fan-out routines. The migration produced startling results: system throughput remained identical at 120,000 requests per minute, but code lines dropped by 45%, onboarding time for new developers fell from three months to two weeks, and production error diagnosis time decreased by 80%.

Case Study 2: Core Banking Payment Orchestrator Survives Downstream Outages

A European fintech platform processes credit card authorizations and bank transfers. During a major cloud network outage, one of their downstream fraud detection vendors experienced severe latency, with response times jumping from 25ms to over 8 seconds. Under their previous platform thread model, all 1,000 Tomcat worker threads quickly became locked waiting for the fraud API. The entire banking platform froze, blocking unrelated deposits and withdrawals.

After upgrading to Java 25 Virtual Threads, the payment gateway handles over 40,000 concurrent waiting requests simultaneously. When the fraud vendor slowed down, the JVM simply parked the virtual threads in heap memory, consuming less than 50MB of RAM. Carrier threads remained completely free to process other bank transactions. The platform achieved 99.999% availability during downstream vendor incidents.

Case Study 3: High-Frequency Telemetry Ingestion Hub Scales to 250,000 Persistent Connections

An IoT logistics provider tracks commercial truck fleets worldwide. Over 250,000 GPS hardware beacons maintain continuous HTTP/2 connections to push telemetry updates every few seconds. Under a traditional platform thread model, the company required 50 separate micro-instances to maintain the connection load without running out of operating system thread memory.

By deploying a lightweight Java 25 gateway using virtual threads and non-blocking HttpClient connection pools, the company consolidated the entire fleet connection ingestion layer into three high-memory server instances. Infrastructure costs dropped by 72%, while CPU utilization improved from 18% to 65%.

Production Anti-Patterns and Pitfalls

Anti-Pattern 1: Pooling Virtual Threads

The most common anti-pattern among experienced Java developers is attempting to pool virtual threads using fixed-size thread pools (such as Executors.newFixedThreadPool(500)). In classic Java, pooling was essential because creating OS threads was expensive. In Loom, virtual threads are cheap, disposable tasks designed to be created and discarded in millions.

Solution: Never pool virtual threads. Use Executors.newVirtualThreadPerTaskExecutor() or create threads on demand with Thread.ofVirtual().start(runnable).

Anti-Pattern 2: Pinning Carrier Threads with synchronized Blocks

In early preview versions of Loom, if a virtual thread executed a blocking network read inside a synchronized block or method, the JVM was unable to unmount the virtual thread from its carrier thread—a condition known as thread pinning. The underlying carrier OS thread was locked, defeating non-blocking scalability.

Solution: Although Java 24 and Java 25 significantly alleviated synchronized monitor pinning, modern enterprise applications should prefer using java.util.concurrent.locks.ReentrantLock instead of synchronized blocks around blocking I/O calls.

Anti-Pattern 3: Omitting Granular Timeouts on Outbound HttpClient Calls

Because virtual threads make blocking calls so effortless, developers often forget to configure explicit request timeouts. If a remote downstream service hangs indefinitely without closing the TCP socket, the virtual thread will remain parked in memory forever. Over weeks of production traffic, millions of abandoned virtual threads will accumulate, leading to eventual OutOfMemoryError heap exhaustion.

Solution: Always set explicit timeouts on both the HttpClient connect configuration (connectTimeout) and on every individual HttpRequest (timeout(Duration)).

Frequently Asked Questions

Are Virtual Threads faster than platform threads for CPU-intensive tasks?

No. Virtual threads do not make CPU-bound computations (such as cryptography, image processing, or numerical matrix calculations) faster. Virtual threads excel specifically at high-throughput I/O-bound tasks (network calls, database queries, file operations) where threads spend time waiting for external responses.

Can I use Java 25 Virtual Threads with relational databases via JDBC?

Yes. Unlike reactive programming which required non-blocking database drivers like R2DBC, Virtual Threads work seamlessly with standard blocking JDBC drivers and connection pools (such as HikariCP). When JDBC waits for database socket reads, the JVM automatically unmounts the virtual thread.

How many Virtual Threads can run simultaneously on a single machine?

Because a virtual thread requires only about 1 kilobyte of heap memory at initialization, a modern server with 16 gigabytes of RAM can effortlessly maintain hundreds of thousands to millions of concurrent virtual threads.

How does HttpClient achieve HTTP/2 multiplexing with Virtual Threads?

When multiple virtual threads call the same remote host over HTTP/2, HttpClient automatically multiplexes their requests as independent streams over a single shared TCP connection. Each virtual thread unmounts independently while awaiting its stream response, maximizing network efficiency.

What is Structured Concurrency in Java?

Structured Concurrency (JEP 480) treats groups of related concurrent tasks running in different threads as a single unit of work. It ensures that subtasks are strictly scoped within a syntactic block, preventing thread leaks and providing automatic cancellation if any subtask fails.

Companion Workshop Code

The companion workshop project for this guide provides a complete Java 25 asynchronous scatter-gather gateway and resilient HttpClient factory without external web frameworks:

For related networking and concurrency architectures, explore our in-depth guides on High-Concurrency APIs with Java 25 Virtual Threads, Spring Cloud Gateway with Virtual Threads, Resilience4j Circuit Breakers with Virtual Threads, HTTP/3 and QUIC Protocol Head-of-Line Blocking, and WebSockets vs. SSE vs. WebTransport.

Official References and Specifications

Comments