When Java 21 was released with virtual threads, everyone celebrated, including myself.
Then someone's app fell over at 2AM. And the crash wasn't where anyone expected. 😅
The promise everyone heard
Project Loom introduced Virtual threads in Java 21, which made it possible to create a large number of threads effortlessly.
You also don't need to fine-tune the thread pools on your platform.
The concept was straightforward. Write blocking code, make it perform like asynchronous code. Voilà ! Simply insert Thread.ofVirtual() and witness a substantial increase in your throughput.
That's what happened. And surprisingly, it paid off in the compute layer.
The party nobody warned you about
Here are a few things that celebration left out.
Your application was granted the ability to execute 10,000 requests simultaneously. But unfortunately, your database was not ready for it.
Connection pools, such as HikariCP, have a maximum fixed size; it was optimized for your previous existence when platform threads limited concurrency on your behalf.
Virtual threads no longer act as your bottleneck, so if you have 10,000 requests all starving for resources and reaching for a pool of only 20 connections, that pool becomes the new choke point.
What about the other 9,980 people? They stand in line, they wait, and finally, they give up.
You haven't really increased the speed of your app.
Compute concurrency is not resource concurrency
This is the painful truth. Your limitation was never the threads.
They unintentionally limited the rate, a basic function, but enough to prevent overwhelming your database.
We didn't realize that accidental protection was being deleted by virtual threads.
The only constant is change.
→ Compute concurrency is basically free now.
→ Resource concurrency is still finite.
→ Removing a bottleneck upstream just relocates it downstream.
→ A fixed pool behind infinite threads is a queue with extra steps.
Yes, your database has run out of connections. Your downstream APIs are running out of calls. Your file handles are running out.
None of them care that you can spawn a million virtual threads.
What redesigning both layers actually means
I don't possess a magical configuration line.
With the mental model, everything changes. You no longer view the pool as a detail, but as the very ceiling itself.
Here are a couple of things I would consider carefully:
→ Size your connection pool against what the database can survive, not what your app wants to send.
→ Add explicit backpressure so requests fail fast instead of piling up invisibly.
→ Watch pool wait time, not just request throughput. Throughput lies when everything is stuck in a queue.
→ Treat every finite downstream resource as its own concurrency budget.
The uncomfortable truth is that virtual threads make the pool the star of the show. It used to hide behind your thread limits. Now it's naked.
If you don't measure and optimize your database based on high percentiles of request latency, you might not realize it's the bottleneck.
The takeaway
Virtual threads are really awesome. I'm not changing my tune on that.
They are not simply a scale button. They are a reallocation of load, and the load lands wherever you have a fixed resource pool.
Increasing the flow rate at the top of the pipe without changing the width of the bottom doesn't increase the amount of water going through.
Therefore, before you implement your next Loom rollout, take a moment to check the readiness of your HikariCP max size for the party.
Did virtual threads move your bottleneck downstream, or did you redesign your resource layer to match? I want to hear what broke first.
Top comments (0)