Pavan Rangani

HomeBlogSpring Boot 3.4 Virtual Threads in Production: Complete Migration Guide

Spring Boot 3.4 Virtual Threads in Production: Complete Migration Guide

By Pavan Rangani · February 20, 2026 · Java & Spring

Spring Boot 3.4 Virtual Threads in Production: Complete Migration Guide

Spring Boot with Virtual Threads in Production: Configuration, Pitfalls, and Benchmarks

Java virtual threads promise massive concurrency without reactive complexity, and Spring Boot virtual threads make enabling them a single configuration property. However, production deployment requires understanding connection pool sizing, thread pinning issues, and how virtual threads interact with existing frameworks. Therefore, this guide covers the practical aspects of running Spring Boot with virtual threads — from initial setup to production-hardened configuration — so you can adopt them with eyes open rather than discovering the sharp edges during an incident.

Enabling Spring Boot Virtual Threads: One Property, Big Impact

Spring Boot 3.2+ supports virtual threads natively, building on the platform feature that arrived as a permanent part of the JDK in Java 21 (JEP 444). Enable them with a single property, and every request handler runs on a virtual thread instead of a platform thread. The Tomcat or Jetty connector accepts requests and dispatches each one to a fresh virtual thread, allowing thousands of concurrent connections without the traditional thread pool bottleneck. Crucially, the programming model stays the same — you keep writing straightforward blocking code, and the JVM does the hard part of multiplexing it onto a small pool of carrier threads.

# application.yml — enable virtual threads
spring:
  threads:
    virtual:
      enabled: true

# That's it. Every @RestController, @Service method now runs on a virtual thread.
# No code changes needed — your existing blocking code works as-is.
// What changes internally:
// Before (platform threads): Tomcat thread pool of 200 threads
//   → Max 200 concurrent requests
//   → Each thread: ~1MB stack memory
//   → Total: ~200MB for thread stacks
//
// After (virtual threads): Unlimited virtual threads
//   → Thousands of concurrent requests
//   → Each virtual thread: ~1KB
//   → Total: ~1MB for 1000 virtual threads

@RestController
public class OrderController {
    @GetMapping("/orders/{id}")
    public OrderDTO getOrder(@PathVariable Long id) {
        // This method runs on a virtual thread
        // Blocking calls are fine — JVM parks the virtual thread
        Order order = orderRepository.findById(id).orElseThrow();
        Customer customer = customerClient.getCustomer(order.getCustomerId());
        List<Payment> payments = paymentClient.getPayments(order.getId());

        return OrderDTO.from(order, customer, payments);
    }

    // All three blocking calls (DB, HTTP, HTTP) execute on the virtual thread
    // JVM automatically parks/unparks the virtual thread during I/O waits
    // A platform thread pool handles the actual I/O operations underneath
}
Spring Boot virtual threads configuration
One configuration property enables virtual threads — no code changes required

Connection Pool Sizing: The Critical Configuration

With platform threads, you had 200 threads and 200 maximum concurrent database connections — naturally balanced. With virtual threads, you might have 10,000 concurrent requests, all trying to get a database connection from a pool of 10. This creates connection pool exhaustion — the most common virtual thread production issue. The threads themselves are no longer the limiting resource; instead, the bottleneck shifts downstream to the database, the connection pool, and any rate-limited external API your handlers call.

# HikariCP configuration for virtual threads
spring:
  datasource:
    hikari:
      maximum-pool-size: 50          # MORE than default 10, LESS than thread count
      minimum-idle: 10
      connection-timeout: 5000       # 5 seconds — fail fast
      idle-timeout: 300000           # 5 minutes
      max-lifetime: 600000           # 10 minutes

      # CRITICAL: Use semaphore to limit concurrent DB access
      # Without this, 10,000 virtual threads compete for 50 connections
      # causing massive thread parking and connection timeout storms
// Semaphore pattern to limit concurrent database access
import java.util.concurrent.Semaphore;

@Configuration
public class DatabaseConfig {
    // Allow max 50 concurrent database operations
    // Matches HikariCP pool size
    private final Semaphore dbSemaphore = new Semaphore(50);

    @Bean
    public Semaphore databaseSemaphore() {
        return dbSemaphore;
    }
}

@Service
public class OrderService {
    private final Semaphore dbSemaphore;
    private final OrderRepository orderRepo;

    public Order getOrder(Long id) throws InterruptedException {
        dbSemaphore.acquire();  // Wait for available permit
        try {
            return orderRepo.findById(id).orElseThrow();
        } finally {
            dbSemaphore.release();
        }
    }
}

// Better approach: use a virtual-thread-aware connection pool
// or configure Spring's TaskDecorator to apply semaphore globally

The general rule: set your connection pool to match the concurrency your database can actually handle (typically 20-100 connections per database instance), not the number of virtual threads. Additionally, monitor connection wait times and pool utilization to find the right balance for your workload. The semaphore acts as an explicit back-pressure valve, but a cleaner alternative in many stacks is to let HikariCP’s own connection-timeout reject excess load quickly so callers fail fast instead of piling up.

Back-Pressure and Bulkheading Downstream Calls

Because virtual threads remove the natural ceiling that a fixed thread pool used to impose, you must reintroduce limits deliberately wherever a downstream dependency is finite. A common pattern is to bulkhead each external dependency with its own semaphore or its own bounded executor, so a slow payment gateway cannot consume all the capacity meant for database work. The example below shows a simple per-dependency limiter that keeps one misbehaving service from cascading into a full outage.

// Per-dependency bulkhead — one slow service can't starve the others
@Component
public class PaymentGatewayClient {
    // Cap concurrent calls to the gateway, independent of request volume
    private final Semaphore gatewayLimit = new Semaphore(30);
    private final RestClient restClient;

    public PaymentResult charge(ChargeRequest req) {
        if (!gatewayLimit.tryAcquire()) {
            // Shed load instead of queueing thousands of virtual threads
            throw new ServiceBusyException("payment-gateway saturated");
        }
        try {
            return restClient.post()
                .uri("/charges")
                .body(req)
                .retrieve()
                .body(PaymentResult.class);
        } finally {
            gatewayLimit.release();
        }
    }
}

This is the mental shift virtual threads demand: previously the thread pool sized everything for you, sometimes too conservatively. Now you own concurrency limits explicitly, and you place them at the resource boundaries that actually matter.

Thread Pinning: When Virtual Threads Block Platform Threads

Virtual threads are supposed to park (release the carrier thread) during blocking operations. However, certain scenarios cause “pinning” — where the virtual thread holds onto the carrier thread and cannot release it. This defeats the purpose of virtual threads because pinned threads consume carrier threads just like traditional blocking, and if enough of them pin simultaneously you can starve the carrier pool entirely.

// Pinning scenario 1: synchronized blocks
// On Java 21 the JVM cannot unmount a virtual thread inside a synchronized block
public class LegacyService {
    private final Object lock = new Object();

    public Data getData() {
        synchronized (lock) {           // PINS the virtual thread on Java 21!
            return database.query();     // Blocking I/O inside synchronized = bad
        }
    }

    // Fix: Replace synchronized with ReentrantLock
    private final ReentrantLock lock2 = new ReentrantLock();

    public Data getDataFixed() {
        lock2.lock();                    // ReentrantLock does NOT pin
        try {
            return database.query();     // Virtual thread parks correctly
        } finally {
            lock2.unlock();
        }
    }
}

// Pinning scenario 2: Native methods / JNI calls
// Some JDBC drivers use native calls internally
// Monitor with: -Djdk.tracePinnedThreads=short
// This JVM flag logs when virtual threads get pinned

// Pinning scenario 3: Third-party libraries using synchronized
// Common culprits: older JDBC drivers, some HTTP clients, logging frameworks
// Check library documentation for virtual thread compatibility

Run your application with -Djdk.tracePinnedThreads=short in staging to identify pinning issues. The JVM logs a stack trace every time a virtual thread is pinned, showing exactly which code path caused it. Furthermore, most modern libraries (HikariCP 5+, Apache HttpClient 5, Netty 4.2+) have been updated to avoid pinning. It is also worth noting that JDK 24 (JEP 491) largely eliminates pinning caused by synchronized, so on newer runtimes the first scenario becomes far less of a concern — but plenty of production systems still run on Java 21 LTS, where the advice above remains essential.

Thread pinning debugging in Java
Use -Djdk.tracePinnedThreads=short to detect virtual thread pinning in your application

ThreadLocal, ScopedValue, and Memory Footprint

With platform threads, a thread pool of 200 meant at most 200 copies of any ThreadLocal value. Virtual threads invert this assumption: there can be tens of thousands of them alive at once, so a heavy ThreadLocal — a cached SimpleDateFormat, a fat security context, a buffer — now multiplies across every in-flight request. Consequently, audit your ThreadLocal usage and prefer ScopedValue (JEP 446), which shares an immutable value down the call stack without per-thread copies.

// ScopedValue — immutable, structured, cheap across many virtual threads
public class RequestContext {
    public static final ScopedValue<String> TENANT_ID = ScopedValue.newInstance();

    public <T> T withTenant(String tenant, Callable<T> work) throws Exception {
        // Value is visible only within this dynamic scope, then discarded
        return ScopedValue.where(TENANT_ID, tenant).call(work::call);
    }
}

// Downstream code reads it without an explicit parameter or ThreadLocal copy
String tenant = RequestContext.TENANT_ID.get();

Beyond memory, ScopedValue is safer because it is immutable and automatically cleaned up when the scope exits, so it cannot leak between pooled tasks the way a forgotten ThreadLocal.remove() can.

Performance Benchmarks: Virtual Threads vs Platform Threads

Representative benchmarks show the impact of virtual threads on a typical Spring Boot REST API with database access and downstream HTTP calls. Treat the figures below as illustrative of the shape of the result rather than absolute numbers — your latency profile depends heavily on your database and downstream services.

Benchmark: Spring Boot 3.3 REST API (representative)
  - 3 endpoints: GET order, POST order, GET orders (paginated)
  - Each request: 1 DB query + 1 HTTP call to downstream service
  - Database: PostgreSQL, HikariCP pool size 50
  - Server: 4 vCPU, 8GB RAM

Platform Threads (200 thread pool):
  Concurrent requests:  200 max
  Throughput:           ~4,200 req/s
  p50 latency:         18ms
  p99 latency:         95ms
  Memory:              420MB
  At 500 concurrent:   Thread pool exhaustion, 5s queue wait

Virtual Threads:
  Concurrent requests:  10,000+ (limited by DB pool, not threads)
  Throughput:           ~4,800 req/s (+14%)
  p50 latency:         16ms
  p99 latency:         52ms (~45% better)
  Memory:              190MB (~55% less)
  At 500 concurrent:   No degradation
  At 5,000 concurrent: p99 increases to 180ms (DB pool contention)

Key insight: Virtual threads don't make individual requests faster.
They prevent degradation under high concurrency.
The real win is at 500+ concurrent requests where platform threads
hit pool limits and virtual threads keep running smoothly.

The takeaway from benchmarks like these is consistent across the industry: virtual threads excel for I/O-bound workloads with high concurrency, and they offer little for CPU-bound work, where a fixed pool sized to the core count is still the right tool. If your service spends most of its time computing rather than waiting, virtual threads will not move your throughput.

Production Checklist for Virtual Threads

Before deploying Spring Boot with virtual threads to production, verify these items:

  • Connection pools sized correctly: Set HikariCP, HTTP client pools, and Redis pools based on downstream capacity, not virtual thread count.
  • No thread pinning: Run with -Djdk.tracePinnedThreads=short in staging and fix synchronized blocks that contain I/O operations.
  • ThreadLocal usage audited: Virtual threads create thousands of instances. Large ThreadLocal values waste memory. Consider ScopedValues instead.
  • Back-pressure in place: Add explicit limiters or bulkheads at every finite downstream dependency so load shedding happens cleanly instead of via timeout storms.
  • Monitoring updated: Traditional thread metrics (pool size, active threads) are less meaningful. Monitor connection pool wait time, request queue depth, and actual concurrency instead.
  • Libraries compatible: Verify JDBC driver, HTTP client, and connection pool versions support virtual threads without pinning.
Production monitoring dashboard for Spring Boot
Monitor connection pool wait times and request concurrency — not thread pool metrics

When NOT to Use Virtual Threads

Despite the upside, virtual threads are not a universal win. For CPU-bound services — image processing, heavy serialization, cryptography — they add scheduling overhead without any concurrency benefit, so a conventional bounded pool remains the better choice. Likewise, if your stack is already fully reactive with WebFlux and you are satisfied with it, rewriting to blocking-on-virtual-threads buys little; the two models solve the same problem from different directions. Finally, legacy applications riddled with synchronized blocks around I/O on Java 21 may pin so heavily that the migration causes more harm than good until those hot spots are refactored. In short, reach for virtual threads when your bottleneck is waiting, not computing.

Related Reading:

Resources:

In conclusion, Spring Boot virtual threads deliver significant concurrency improvements with minimal code changes. The critical production concerns are connection pool sizing, thread pinning detection, back-pressure at downstream boundaries, and ThreadLocal memory management. Start with the one-property enablement, test with realistic load in staging, and monitor connection pool metrics in production. Virtual threads don’t make requests faster — they prevent performance degradation under high concurrency.

← Back to all articles