Virtual Threads in Spring Boot: I Tested Them Under Real Load ;— Here’s What Happened
Everyone’s calling it a free performance upgrade. After two weeks of load testing, I’m not so sure it’s that simple.
Virtual Threads in Spring Boot: I Tested Them Under Real Load ;— Here’s What Happened

“Virtual Threads in Spring Boot: I Tested Them Under Real Load ; — Here’s What Happened”
Everyone’s calling it a free performance upgrade. After two weeks of load testing, I’m not so sure it’s that simple.
Virtual threads got promoted almost like magic flip one config flag, handle ten times the concurrent requests, done. That’s the pitch in every conference talk and half the blog posts I’ve read this year.
I wanted real numbers, not a slide deck. So I took one of our actual production-traffic-shaped services, ran it under real load with platform threads, then again with virtual threads, and recorded exactly what changed — including the parts that got worse, because those parts never make it into the conference talk.
The Setup
Service: order-processing-api
Baseline traffic pattern: mixed I/O — DB calls, external payment API, Redis cache
Load testing tool: Gatling
Test duration: 10 minutes per run, 3 runs averaged
JDK: 21
@RestController
@RequestMapping("/orders")
public class OrderController {
@PostMapping
public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) {
Order order = orderService.create(request); // DB write, ~15ms
paymentClient.charge(order); // external HTTP call, ~180ms
cacheService.invalidate(order.getCustomerId()); // Redis call, ~5ms
return ResponseEntity.ok(order);
}
}
🎯 Master Any Tech Skills in Just 3 Months 💥 Crack Every Tech Interview with Confidence 🔥 Up to 70% OFF — Limited-Time Offer *👉 **Enroll Now & Start Learning***

That external payment call dominates the request time. This is exactly the kind of I/O-bound, blocking-call-heavy workload virtual threads are supposed to help with the most.
Baseline: Platform Threads
server.tomcat.threads.max=200
Concurrent users: 500
Requests per second: 340
P50 latency: 420ms
P95 latency: 1,850ms
P99 latency: 3,200ms
Thread pool exhaustion: Yes, at ~380 concurrent users
Error rate: 4.2%
With only 200 platform threads available, every request holding a thread for ~200ms while waiting on the payment API meant we hit thread starvation well before we hit any real CPU or memory limit. Requests started queuing, and queued requests started timing out.
Enabling Virtual Threads
<properties>
<java.version>21</java.version>
</properties>
spring.threads.virtual.enabled=true
That’s genuinely the entire configuration change. No code rewrite, no annotation changes, no new dependency.
// This code didn't need to change at all
@PostMapping
public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) {
Order order = orderService.create(request);
paymentClient.charge(order);
cacheService.invalidate(order.getCustomerId());
return ResponseEntity.ok(order);
}
The Results That Matched the Hype
Concurrent users: 500 (same load)
Requests per second: 890
P50 latency: 180ms
P95 latency: 410ms
P99 latency: 680ms
Thread pool exhaustion: No
Error rate: 0.3%
This part of the story is genuinely impressive. At identical load, throughput went from 340 to 890 requests per second — roughly a 2.6x improvement — and P99 latency dropped from 3.2 seconds to 680 milliseconds. The thread starvation that caused platform threads to fall over simply didn’t happen, because virtual threads park cheaply during blocking I/O instead of holding an expensive OS thread hostage.
Pushed further: 1,500 concurrent users
Platform threads: system unusable, >60% error rate, thread pool completely saturated
Virtual threads: RPS 1,240, P99 1.1s, error rate 0.8%
At 3x our original load, platform threads essentially fell over. Virtual threads kept serving requests with degraded but genuinely usable latency. This is the headline result, and it’s real.
What Broke #1:- A Synchronized Block Silently Pinned Threads
Photo by David Pupăză on Unsplash
Two days into testing, throughput mysteriously dropped by almost 40% under sustained load, with no obvious cause in the metrics.
// The culprit
public class InventoryCache {
private final Map<String, Integer> cache = new HashMap<>();
public synchronized void updateStock(String productId, int quantity) {
cache.put(productId, quantity);
externalStockService.notify(productId, quantity); // blocking call inside synchronized block
}
}
Virtual threads that block inside a synchronized block get pinned to their underlying platform (carrier) thread instead of being able to park and free it up. That external call inside the synchronized block was silently defeating the entire benefit of virtual threads for every request that hit this code path.
// The fix: replace synchronized with a lock that doesn't pin
public class InventoryCache {
private final Map<String, Integer> cache = new ConcurrentHashMap<>();
private final ReentrantLock lock = new ReentrantLock();
public void updateStock(String productId, int quantity) {
cache.put(productId, quantity);
lock.lock();
try {
externalStockService.notify(productId, quantity);
} finally {
lock.unlock();
}
}
}
ReentrantLock allows a virtual thread to park properly while waiting, without pinning it to a carrier thread. After this fix, throughput recovered fully.
Lesson learned:- virtual threads don’t automatically fix every blocking pattern in your codebase. synchronized blocks containing blocking I/O are a real, silent performance trap and they're common enough in older codebases that this is worth an explicit audit before rolling virtual threads out broadly.
What Broke #2:- ThreadLocal Usage Exploded Memory
Our request-tracing implementation used ThreadLocal to store a correlation ID per request completely standard, works fine with platform threads.
public class TracingContext {
private static final ThreadLocal<String> correlationId = new ThreadLocal<>();
public static void set(String id) {
correlationId.set(id);
}
}
With platform threads, you have at most a few hundred threads, so a few hundred ThreadLocal values is nothing. With virtual threads, we were routinely running tens of thousands of concurrent virtual threads under load — and each one carried its own ThreadLocal instance.
Platform threads: ~200 ThreadLocal instances at peak
Virtual threads: ~18,000 ThreadLocal instances at peak
Heap impact: noticeable GC pressure increase, more frequent minor collections
// The fix: Scoped Values (JDK 21+) instead of ThreadLocal for virtual-thread-heavy code
public class TracingContext {
private static final ScopedValue<String> correlationId = ScopedValue.newInstance();
public static void runWithCorrelationId(String id, Runnable task) {
ScopedValue.where(correlationId, id).run(task);
}
}
Scoped Values are designed specifically for high-volume virtual thread usage they don’t carry the same per-thread memory overhead that ThreadLocal does at this scale.
Lesson learned:- patterns that were completely fine at hundreds of threads scale can become genuinely problematic at tens of thousands of threads scale. Virtual threads change your concurrency numbers by orders of magnitude, and some existing code wasn’t written with that order of magnitude in mind.
What Broke #3:- CPU-Bound Work Showed No Improvement (As Expected, But Worth Confirming)
Photo by Stan Hutter on Unsplash
To be thorough, I also tested a CPU-heavy endpoint image resizing, no external I/O at all.
@PostMapping("/images/resize")
public ResponseEntity<byte[]> resizeImage(@RequestParam MultipartFile file) {
return ResponseEntity.ok(imageProcessor.resize(file.getBytes(), 800, 600));
}
Platform threads: 145 RPS
Virtual threads: 142 RPS (essentially identical, within margin of error)
No surprise here, but worth stating plainly: virtual threads help with I/O-bound blocking, not CPU-bound computation. If your bottleneck is genuinely CPU work, virtual threads won’t move the needle, and anyone telling you otherwise hasn’t actually tested it.
The Honest Summary

What I’d Actually Tell a Team Considering This
✅ Enable it for genuinely I/O-bound services — the win is real and significant
✅ Audit every synchronized block that contains blocking calls, before rollout
✅ Check ThreadLocal usage patterns if you expect very high concurrency
❌ Don't expect any improvement on CPU-bound workloads
❌ Don't flip the flag fleet-wide without load testing your actual traffic shape first
Final Thoughts
- The core promise of virtual threads is real for I/O-bound workloads under real concurrent load, the throughput and latency improvements were substantial, not marginal.
- The one-line configuration change is genuinely just one line, but “no code changes required” undersells the audit work needed to catch pinning and ThreadLocal issues first.
synchronizedblocks containing blocking calls are the most common trap, and they fail silently no error, no crash, just quietly worse performance than expected.- CPU-bound workloads see no benefit at all, which is expected but worth confirming before anyone assumes this is a universal performance fix.
- This isn’t a flag you flip and forget it’s a genuine architectural shift that rewards teams who test it properly and punishes teams who don’t.
One-Line Wisdom
Virtual threads deliver on the hype for I/O-bound work but only for teams willing to actually load test before believing the conference talk.
Thanks for reading………………………
Thank you for being a part of the community
Before you go:

👉 Be sure to clap and follow the writer ️👏️️
👉 Follow us: **Linkedin| [Medium](https://medium.com/codetodeploy)**
👉 CodeToDeploy Tech Community is live on Discord — **Join now!**
Disclosure: This post includes affiliate and partnership links.
메타데이터
- post_id
- 4d3fc85268e2
- slug
- virtual-threads-in-spring-boot-i-tested-them-under-real-load-heres-what-happened-4d3fc85268e2
- url
- https://medium.com/codetodeploy/virtual-threads-in-spring-boot-i-tested-them-under-real-load-heres-what-happened-4d3fc85268e2
- canonical_url
- https://medium.com/codetodeploy/virtual-threads-in-spring-boot-i-tested-them-under-real-load-heres-what-happened-4d3fc85268e2
- author_url
- https://medium.com/@ravendrakumar22000
- status
- ok
- fetched_at
- 2026-08-06 21:42:47