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.

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).

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:
- 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. - 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!
- 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.

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
ThreadLocalvariables (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) |

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:
- GitHub Repository: https://github.com/salahtobok/learn.webcodein.workshop/tree/main/networking/java-25-httpclient-virtual-threads-networking
- Core Classes:
ResilientHttpClientFactory,ScatterGatherGateway,DownstreamTarget,AggregatedResponse, andGatewaySummary. - Architectural Verification: ArchUnit rules verifying layer isolation, record immutability, and zero Spring Boot dependencies.
- Build Command:
mvn clean test(6/6 tests passing on Java 25 with embedded JDK HttpServer).
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.
