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 (1)
The reframe that stuck with me: virtual threads didn't create the bottleneck, they made it quiet.
Before Loom, a saturated system failed loudly. The platform pool rejected work, you got a RejectedExecutionException, threads-in-use flatlined at max. Ugly — but visible in a metric you already had on a dashboard.
Now the exact same saturation shows up as time spent inside getConnection(). Throughput looks fine. Thread count looks fine. CPU looks fine. The only place it surfaces is a metric most teams never graphed: connection acquire time and hikaricp_connections_pending. The failure didn't get worse. It got harder to see. That's the real reason it lands at 2AM instead of during the load test.
One concrete thing worth checking before you blame the pool: on JDK 21–23 a virtual thread pinned its carrier inside a synchronized block, and plenty of JDBC drivers and pool internals are synchronized. That gives you the mirror image — starvation with an idle database. JDK 24 (JEP 491) removed that pinning. Worth knowing which of the two you're actually looking at, because the fixes point in opposite directions.
The rule I'd write on the wall: put the limit on the resource, not on the threads. A semaphore sized to what the database survives fails fast and says so. A pool queue hides the same number behind a wait.
We keep this one written down as a lesson, because it's the kind of thing every team rediscovers at 2AM.