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:
| Surface | Before (typical) | With virtual threads |
|---|---|---|
| Embedded Tomcat / Jetty request handling | Platform-thread pool (server.tomcat.threads.*) | Each request runs on a virtual thread |
Auto-configured applicationTaskExecutor | ThreadPoolTaskExecutor (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
| Workload | Prefer |
|---|---|
| Spring MVC + blocking JDBC / RestClient / Feign-style clients | Virtual 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 carriers | Measure; isolate if needed |
| Already-reactive WebFlux end-to-end with non-blocking drivers | Stay 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 Tomcat | Virtual threads enabled | |
|---|---|---|
| Concurrent blocked requests | Roughly capped by Tomcat max threads | Can grow with traffic |
| Typical first failure mode | Request queue / 503 under load | Hikari timeout / downstream overload |
| What to tune first | server.tomcat.threads.max | hikari.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-sizeto “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 +
RestClientsequential 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
ThreadLocalcosts still matter on older 21.x lines and custom context - Pool-tuning muscle memory (
server.tomcat.threads.maxas 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.