Skip to main content
Java

Virtual Threads vs Reactive: When to Pick Each

Ravinder··7 min read
JavaVirtual ThreadsReactiveSpring BootConcurrencyJava 21
Share:
Virtual Threads vs Reactive: When to Pick Each

The Problem They Both Solve

Threads are expensive. A platform thread in the JVM consumes roughly 1MB of stack space and requires an OS-level context switch every time it is scheduled. This becomes a serious constraint for I/O-bound applications: a traditional thread pool-based server processing 10,000 concurrent requests needs 10,000 threads, 10GB of memory just for stacks, and the scheduler's cooperation to keep them all moving.

Two solutions emerged. The reactive approach (Project Reactor, RxJava, Vert.x) handles I/O non-blockingly by pushing work through event loops and callback chains. Virtual threads, released as a production feature in Java 21, handle I/O by parking cheaply on a small number of OS threads and resuming transparently.

Both achieve high concurrency. They take completely different paths to get there.


The Models

Platform Threads (the problem)

sequenceDiagram participant T1 as Thread 1 participant T2 as Thread 2 participant T3 as Thread 3 participant DB as Database T1->>DB: Query (BLOCKING) Note over T1: Blocked — OS thread parked T2->>DB: Query (BLOCKING) Note over T2: Blocked — OS thread parked T3->>DB: Query (BLOCKING) Note over T3: Blocked — OS thread parked Note over T1,T3: All three threads consuming OS resources doing nothing DB-->>T1: Result DB-->>T2: Result DB-->>T3: Result

Every request needs a thread. Every thread blocks on I/O. Thread pool exhausted = request queuing = high latency.

Virtual Threads (Java 21)

Virtual threads are managed by the JVM, not the OS. When a virtual thread blocks on I/O, the JVM parks it and unmounts it from its carrier (platform) thread. The carrier thread is then free to run another virtual thread.

// Exactly the same blocking code you always wrote
// Virtual threads make it scale
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            // This blocks — but the carrier thread is NOT blocked
            String result = httpClient.get("/api/data");
            process(result);
        });
    }
}

The key insight: you write familiar, imperative, blocking code. The JVM makes it non-blocking behind the scenes.

flowchart TD subgraph Carrier["2 Carrier Threads (OS threads)"] CT1["Carrier Thread 1"] CT2["Carrier Thread 2"] end subgraph VTs["Virtual Threads (millions possible)"] VT1["VT-1 running"] VT2["VT-2 parked on I/O"] VT3["VT-3 running"] VT4["VT-4 parked on I/O"] VT5["VT-5 waiting to run"] end CT1 --- VT1 CT1 -.->|"will pick up when VT-2 resumes"| VT2 CT2 --- VT3 CT2 -.->|"will pick up when VT-4 resumes"| VT4 VT5 -.->|"queued"| CT1 style VT2 fill:#FEF3C7,stroke:#F59E0B style VT4 fill:#FEF3C7,stroke:#F59E0B

Reactive Programming

Reactive handles I/O by never blocking at all. Work is expressed as a pipeline of operators. An event loop drives execution. No thread waits — it subscribes to a result and gets called back.

// Reactive — non-blocking but complex
public Mono<String> processRequest(String input) {
    return webClient.get()
        .uri("/api/data")
        .retrieve()
        .bodyToMono(String.class)
        .flatMap(data -> processAsync(data))
        .onErrorResume(e -> Mono.just("fallback"))
        .timeout(Duration.ofSeconds(5));
}

The pipeline is powerful. It also fundamentally changes how you think about code flow, error handling, and debugging.


The Trade-offs

graph LR subgraph VT["Virtual Threads — Strengths"] V1["Familiar imperative style"] V2["Easy migration from thread pools"] V3["Debugger works normally"] V4["Stack traces are readable"] V5["Existing JDBC/blocking libs work"] end subgraph React["Reactive — Strengths"] R1["True backpressure support"] R2["Streaming (SSE, WebSocket)"] R3["Fine-grained operator composition"] R4["Built-in retry/timeout/rate limit"] R5["Memory efficient for huge fan-out"] end subgraph VTW["Virtual Threads — Weaknesses"] VW1["Pinning risk with synchronized blocks"] VW2["No native backpressure"] VW3["CPU-bound tasks gain nothing"] end subgraph ReactW["Reactive — Weaknesses"] RW1["Steep learning curve"] RW2["Hard to debug stack traces"] RW3["Requires reactive-aware drivers"] RW4["Context propagation is painful"] end

When Virtual Threads Win

Standard REST APIs and microservices

If you are running a Spring Boot application that handles HTTP requests involving database queries, cache lookups, and downstream HTTP calls, virtual threads are the answer. The migration is almost trivial:

// Spring Boot 3.2+ — enable virtual threads in one line
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
    
    @Bean
    public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
        return protocolHandler -> {
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }
}

Or with Spring Boot 3.2+ spring.threads.virtual.enabled=true property — that is literally all it takes.

Benchmark results from a Spring Boot 3.2 application with 10ms simulated database latency:

Concurrency comparison — 10ms I/O latency, 4 core machine
─────────────────────────────────────────────────────────
                    Thread pool    Virtual threads
Throughput (req/s)  4,100          42,800
p50 latency          12ms           11ms
p99 latency          380ms          14ms
Memory (heap)        2.1GB          0.8GB
─────────────────────────────────────────────────────────

The throughput improvement is dramatic for I/O-bound workloads. The p99 latency improvement is even more important — the thread-pool p99 reflects queuing under load.

JDBC and blocking libraries

All existing JDBC drivers, HTTP clients, and blocking I/O libraries work with virtual threads without any code changes. This is the decisive advantage over reactive, which requires reactive-native drivers for every I/O operation.

// Works perfectly with virtual threads — no changes needed
@Repository
public class UserRepository {
    
    @Transactional
    public User findById(long id) {
        // This blocks — virtual thread parks, carrier continues
        return jdbcTemplate.queryForObject(
            "SELECT * FROM users WHERE id = ?",
            userRowMapper, id
        );
    }
}

When Reactive Wins

Streaming and server-sent events

When you are streaming data to many clients over a long-lived connection, reactive's backpressure model is essential. Virtual threads do not give you backpressure — a slow consumer will still consume memory proportional to the unprocessed data.

// Reactive — natural fit for streaming
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamEvents() {
    return Flux.interval(Duration.ofSeconds(1))
        .map(sequence -> ServerSentEvent.<String>builder()
            .id(String.valueOf(sequence))
            .event("price-update")
            .data(priceService.getCurrentPrice())
            .build())
        .onBackpressureDrop(); // Drop events for slow consumers
}

Complex async pipelines with fan-out

When you need to call three services in parallel, combine their results, apply transformations, and handle partial failures with fallbacks, reactive operators give you a clean, composable way to express it.

// Reactive fan-out and composition
public Mono<OrderSummary> buildOrderSummary(String orderId) {
    return Mono.zip(
        orderClient.getOrder(orderId),
        inventoryClient.getStock(orderId),
        shippingClient.getStatus(orderId)
    )
    .map(tuple -> OrderSummary.from(
        tuple.getT1(), tuple.getT2(), tuple.getT3()
    ))
    .onErrorResume(InventoryException.class, 
        e -> buildPartialSummary(orderId));
}

This is expressible with virtual threads and CompletableFuture, but the reactive operator model is more ergonomic for deeply nested parallel flows.


The Pinning Problem

The main footgun with virtual threads is pinning. When a virtual thread executes code inside a synchronized block while blocking on I/O, it cannot unmount from the carrier thread. The carrier thread is pinned and unavailable for other virtual threads.

// This pins the carrier thread — avoid
synchronized (lock) {
    String result = httpClient.get("/api/data"); // Blocks AND is inside synchronized
}
 
// Use ReentrantLock instead
private final ReentrantLock lock = new ReentrantLock();
 
lock.lock();
try {
    String result = httpClient.get("/api/data"); // VT can park here
} finally {
    lock.unlock();
}

Detect pinning with JVM flags:

-Djdk.tracePinnedThreads=full

This logs a stack trace every time a carrier thread is pinned. Run this during load testing to find pinning hot spots before production.


Migration Path from Platform Threads

If you are on Spring Boot 2.x or a traditional thread-pool-based application:

flowchart LR S1["Spring Boot 2.x\nThread pool\n(200 threads)"] -->|"Step 1: Upgrade to\nSpring Boot 3.2+"| S2 S2["Spring Boot 3.2\nThread pool\n(still the default)"] -->|"Step 2: Enable\nvirtual threads\n(one property)"| S3 S3["Spring Boot 3.2\nVirtual threads\n(millions of threads)"] -->|"Step 3: Fix pinning\n(synchronized → ReentrantLock)"| S4 S4["Production\nVirtual threads\nNo pinning"] style S1 fill:#FEF3C7,stroke:#F59E0B style S4 fill:#D1FAE5,stroke:#10B981

The migration is genuinely easy. The vast majority of codebases need only the property change. Pinning fixes are surgical — run the pinning tracer and fix only what it surfaces.


The Decision Framework

Answer these questions:

  1. Is your workload I/O-bound (DB, HTTP, cache)? Both help. Virtual threads are simpler.
  2. Do you need to stream data to clients (SSE, WebSocket)? Use reactive.
  3. Do you need fine-grained backpressure? Use reactive.
  4. Are you migrating an existing blocking application? Virtual threads — minimal code changes.
  5. Is your team already comfortable with reactive programming? Stay reactive.
  6. Are you starting fresh? Virtual threads unless you need streaming or backpressure.

The answer for most enterprise backend services in 2026 is virtual threads. They give you the concurrency of reactive with the simplicity of blocking code. Reactive remains the right answer for streaming workloads, real-time pipelines, and cases where backpressure is non-negotiable.

The choice is not permanent. You can migrate from one to the other. Pick based on the problem you have today.