You turn on one property, redeploy, and expect the thread pool pain to vanish. Sometimes throughput climbs. Sometimes the database melts first.

One Spring Boot flag is not a migration. Virtual threads make cheap concurrency available; they do not invent connection pool slots, CPU cores, or safe ThreadLocal habits. This post is the Boot-side sequel to Virtual Threads: what the flag actually changes, when MVC services win, and what you must size after you flip it.

Read the language post first if pinning, carriers, or “virtual threads do not add cores” is still fuzzy — then continue here.

What the flag turns on

Spring Boot 3.2+ on Java 21+:

spring:
  threads:
    virtual:
      enabled: true

When that property is true, Boot rewires two important surfaces:

SurfaceBefore (typical)With virtual threads
Embedded Tomcat / Jetty request handlingPlatform-thread pool (server.tomcat.threads.*)Each request runs on a virtual thread
Auto-configured applicationTaskExecutorThreadPoolTaskExecutor (pool settings apply)SimpleAsyncTaskExecutor that starts a virtual thread per task

That second row matters more than people notice. @Async, Spring MVC async request processing, and other callers of the application task executor stop using a fixed pool. Pool-oriented spring.task.execution.* knobs are largely ignored once virtual threads own that executor — name prefix and TaskDecorator still apply; core/max pool size do not.

Note: You still need Java 21+. Prefer Java 25 LTS when Loom is central to the service: JEP 491 largely removes synchronized pinning, which the language post covers in depth.

Mental model in a Boot app

Client
  |
  v
Tomcat acceptor  -->  virtual thread per request
                          |
                          +--> @RestController (blocking OK)
                          +--> JDBC / RestClient (block = unmount carrier)
                          +--> @Async via applicationTaskExecutor (also VT)
                          |
                          v
               Shared resources you must bound:
               Hikari max pool, HTTP client max connections,
               downstream rate limits, semaphores

Platform-thread Tomcat capped concurrent blocked requests near server.tomcat.threads.max (often 200). With virtual threads that ceiling moves: thousands of requests can wait on I/O at once. The new ceiling is whatever shared resource you forgot to size — usually the database.

When Boot + virtual threads win

WorkloadPrefer
Spring MVC + blocking JDBC / RestClient / Feign-style clientsVirtual threads
Fan-out inside a request (several independent HTTP/DB calls)Virtual threads + explicit bounds
Mostly CPU (crypto, image codecs, tight number crunching)Platform threads / sized compute pool
Heavy native / JNI that blocks carriersMeasure; isolate if needed
Already-reactive WebFlux end-to-end with non-blocking driversStay reactive — VT is not a free upgrade path

Virtual threads shine when you want to keep readable blocking MVC code and still absorb high concurrent wait time. They are a poor substitute for adding cores to a CPU-bound worker.

Lab: small REST service, before and after the bottleneck

Lab goal: a Boot 3.5.x (or 3.2+) MVC service that loads an order by calling JDBC and a downstream HTTP API. You will enable virtual threads, then deliberately bound Hikari and RestClient so concurrency cannot stampede the dependencies.

Project skeleton

pom.xml essentials:

<properties>
  <java.version>21</java.version>
</properties>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
  </dependency>
  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
  </dependency>
</dependencies>

Use H2 in-memory if you only want the HTTP path for a laptop smoke test; the pool-sizing lesson is the same.

Configuration

application.yml:

spring:
  threads:
    virtual:
      enabled: true
  datasource:
    url: jdbc:postgresql://localhost:5432/orders
    username: app
    password: secret
    hikari:
      # Bound DB concurrency — do NOT "match" unbounded virtual threads
      maximum-pool-size: 20
      connection-timeout: 3000

server:
  tomcat:
    # With VT enabled, max platform worker count is less of a request ceiling;
    # keep Tomcat sane for acceptors / legacy paths, but size pools below.
    threads:
      max: 200

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,threaddump

Controller and clients

Blocking code on purpose — that is the point:

@RestController
@RequestMapping("/api/orders")
class OrderController {

  private final OrderRepository orders;
  private final RestClient inventory;

  OrderController(OrderRepository orders, RestClient.Builder builder) {
    this.orders = orders;
    this.inventory = builder.baseUrl("http://inventory:8082").build();
  }

  @GetMapping("/{id}")
  OrderDetail get(@PathVariable String id) {
    // Runs on a virtual thread when spring.threads.virtual.enabled=true
    Order order = orders.findById(id); // JDBC blocks
    InventoryStock stock = inventory.get()
        .uri("/api/stock/{sku}", order.sku())
        .retrieve()
        .body(InventoryStock.class); // HTTP blocks
    return new OrderDetail(order, stock);
  }
}

record Order(String id, String sku, int qty) {}
record InventoryStock(String sku, int available) {}
record OrderDetail(Order order, InventoryStock stock) {}

Bound the HTTP client the same way you bound Hikari. JDK HttpClient / Apache / Jetty client max connections are the second stampede valve:

@Configuration
class HttpClientConfig {

  @Bean
  RestClient.Builder restClientBuilder() {
    HttpClient jdk = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(2))
        // Optional: executor for the client — prefer bounds via
        // connection limits / semaphores over unbounded fan-out
        .build();
    return RestClient.builder()
        .requestFactory(new JdkClientHttpRequestFactory(jdk));
  }
}

For stricter caps inside a single request’s fan-out, wrap work in a semaphore or a small structured scope rather than spawning unbounded virtual threads against one dependency.

Prove requests ride virtual threads

Hit an endpoint and log the carrier:

@GetMapping("/_lab/thread")
Map<String, Object> thread() {
  Thread t = Thread.currentThread();
  return Map.of(
      "name", t.getName(),
      "virtual", t.isVirtual(),
      "toString", t.toString());
}
curl -sS http://localhost:8080/_lab/thread | jq .

Expect "virtual": true and a name like tomcat-handler-… when the flag is on. With the flag off you get a platform worker from Tomcat’s pool.

Before / after concurrency sketch

Platform-thread TomcatVirtual threads enabled
Concurrent blocked requestsRoughly capped by Tomcat max threadsCan grow with traffic
Typical first failure modeRequest queue / 503 under loadHikari timeout / downstream overload
What to tune firstserver.tomcat.threads.maxhikari.maximum-pool-size, HTTP max connections, timeouts

Load-test only after pools and timeouts are intentional. A sudden “we enabled VT” deploy without Hikari limits is how you DDoS your own Postgres.

Gotchas that show up in Boot first

Pinning on older JDK lines

On Java 21–23, a virtual thread pinned inside synchronized while blocking still holds a carrier. Framework and app code full of synchronized + I/O is the usual surprise. Prefer ReentrantLock (or shrink critical sections) on those lines; move to Java 25 LTS when you can. Keep JFR pinning events in the toolbox during adoption.

ThreadLocal and request context

Caches and MDC-style context that assumed “a few hundred long-lived workers” behave differently when every request is a short-lived virtual thread. Prefer explicit arguments, request-scoped beans used carefully, or — on Java 25 — Scoped Values for immutable request IDs and principals.

Spring Security and many observability stacks still use ThreadLocal today; they work, but treat large custom ThreadLocal payloads as a smell under VT.

Observability

Thread names still help (tomcat-handler-0), but dumps get noisier with thousands of virtual threads. Rely on:

  • Actuator metrics / Micrometer timers on controllers and clients
  • Distributed traces (traceparent) across gateway and service — see Spring Cloud Gateway MVC for edge correlation habits
  • Connection pool gauges (Hikari active / pending) as first-class SLOs

A thread dump alone is a weak load story once VT is on.

Mixing executors

Custom Executor beans, @Async without understanding the auto-config switch, and libraries that create their own fixed pools can silently keep platform-thread bottlenecks. After enabling VT, inventory every executor: request path, @Async, schedulers, and HTTP clients.

Note: Task scheduling also gets a virtual-thread-friendly scheduler when the flag is on; pooling-related spring.task.scheduling.* settings are ignored in that mode. Periodic CPU-heavy jobs may still want an explicit platform-thread scheduler.

Cheat sheet

spring.threads.virtual.enabled=true   # Boot 3.2+, Java 21+
Prefer Java 25 LTS for production Loom comfort

Turns on:
  - Tomcat/Jetty request VT
  - applicationTaskExecutor -> SimpleAsyncTaskExecutor (VT)

Size next:
  - spring.datasource.hikari.maximum-pool-size
  - HTTP client max connections / timeouts
  - downstream rate limits

Watch:
  - synchronized + blocking on Java 21–23 (pinning)
  - ThreadLocal sprawl; scoped values on Java 25
  - @Async / custom Executor still on platform pools

Do:

  • Enable VT for I/O-bound Spring MVC services on Java 21+.
  • Bound Hikari and HTTP clients before celebrating load-test numbers.
  • Keep blocking code readable; size shared resources on purpose.

Don’t:

  • Treat the flag as a CPU upgrade.
  • Set maximum-pool-size to “thousands” to match virtual thread counts.
  • Ship VT without pool metrics and timeouts.

Pros and cons

Pros

  • One property moves Servlet request handling (and the app task executor) onto virtual threads
  • Keeps Spring MVC + JDBC + RestClient sequential style under high concurrent wait
  • Pairs cleanly with Servlet-edge gateways that also enable VT

Cons

  • Easy to overwhelm DB and HTTP pools without explicit caps
  • Pinning and ThreadLocal costs still matter on older 21.x lines and custom context
  • Pool-tuning muscle memory (server.tomcat.threads.max as the main dial) becomes misleading
  • Not a substitute for non-blocking design where you already invested in WebFlux end-to-end

Wrap-up

spring.threads.virtual.enabled=true is the Boot on-ramp to the model in Virtual Threads: cheap request-per-task concurrency for blocking I/O. The work after the flag is operational — pool limits, timeouts, traces, and context propagation. On Java 25, lean on Scoped Values for request-scoped immutable data instead of growing ThreadLocal maps.

At the edge, the same Loom story shows up on Gateway Server Web MVC: Servlet filters and proxies that block are fine when virtual threads absorb the wait, as long as every hop bounds what it calls next.

Next optional step Put a Servlet front door on the same blocking stack. Spring Cloud Gateway: Put a Front Door on Your Microservices