Java 21 Virtual Threads: The Concurrency Shift Every Backend Engineer Needs to Understand

Java 21 Virtual Threads: The Concurrency Shift Every Backend Engineer Needs to Understand
Why Project Loom isn’t just another Java feature — it’s a fundamental rethink of how servers handle scale, how Spring Boot apps perform under load, and how much you actually need to spend on infrastructure.
Every few years, Java gets an upgrade that changes not just what you write, but how you think about writing it. Java 8 gave us lambdas and streams, and we started writing code functionally. Java 21 gives us Virtual Threads, and this time, the shift isn’t about syntax — it’s about how your application behaves under real production load, and how much that load costs you.
If you’re building microservices, REST APIs, or anything that talks to a database over a network, this is the single most impactful concurrency change Java has shipped in over a decade. Let’s break down why — and then get specific about what it means for a real Spring Boot application running in production.
First, Let’s Untangle Three Words Developers Constantly Confuse
Before virtual threads make sense, you need to be precise about three terms that get thrown around interchangeably — incorrectly.
Parallel means two things happening at the exact same instant, using separate resources. Think of walking while talking — your legs and your mouth are independent systems, working simultaneously.
Concurrent means multiple tasks making progress over a stretch of time, but sharing one constrained resource. You can talk and drink water in the same minute, but not in the same millisecond — your mouth can only do one at a time. That’s concurrency: progress through fast switching, not true simultaneity.
Asynchronous (non-blocking) is different from both. It’s what happens when a task needs something external — a database response, an API call — and instead of freezing in place waiting, it steps aside and lets something else use the resource it was holding, picking back up once the result is ready.
This third concept is exactly what virtual threads are built around.
Why Traditional Threads Can’t Keep Up
Every thread you’ve created in Java until now — a java.lang.Thread — is a platform thread, mapped one-to-one to a real operating system thread. That mapping is the root of the problem.
OS threads are expensive. They’re heavy to create, heavy to context-switch between, and there’s a hard ceiling on how many your server can realistically hold in memory — typically a few thousand, not millions.
Compute-heavy tasks hit a CPU wall. Spin up more platform threads than you have CPU cores for number-crunching work, and you don’t get more throughput — you get thrashing. The CPU spends its cycles switching between threads instead of doing actual work, like a group of overly polite people at a doorway, endlessly gesturing “after you.”
I/O-heavy apps hit a different wall — a mathematical one. Most backend systems aren’t CPU-bound at all. They spend the overwhelming majority of their time waiting: on a database query, a downstream API, a disk read. There’s a well-known formula for figuring out how many threads actually make sense for a workload like this:
Optimal Threads = CPU Cores / (1 - Blocking Factor)For an I/O-heavy service where requests spend 90% of their time waiting (blocking factor = 0.9), that formula simplifies to:
Optimal Threads = Cores × 10So a beefy server with 1,000 cores tops out around 10,000 usable platform threads. Sounds like a lot — until traffic spikes. If 20,000 requests land at once, only half get processed immediately. The other 10,000 sit in a queue, waiting for a thread to free up — not because the CPU is out of capacity, but because the thread pool is out of room.
The traditional fix is to scale horizontally: spin up more servers to get more threads. That works, but it’s expensive, and it’s solving a threading limitation with hardware money. We’ll come back to exactly why in a moment.
Two Analogies That Make This Click
The 2 AM hotel check-in. You walk up to the front desk. One receptionist, one guest ahead of you, digging through every pocket looking for his passport. Instead of asking you to step aside while he searches — freeing up the desk — the receptionist just stands there, blocked, doing nothing until the guest finds it. That’s a platform thread during I/O: fully occupied, fully idle, refusing to help anyone else.
The restaurant waiter. A waiter takes your order and walks away — to other tables, other orders — while your food cooks. They don’t stand at your table for twenty minutes doing nothing. And when your water glass runs low, you don’t care which waiter refills it, just that it gets refilled.
Virtual threads bring the waiter model to Java. That’s the whole idea in one sentence.
How Virtual Threads Actually Work: One More Level of Indirection
Computer science has a running joke that’s actually a design principle: any problem can be solved with one more level of indirection. Java’s history proves it — pointers in C, polymorphism in OOP, lambdas in Java 8. Virtual threads are the next entry in that lineage.
Instead of a task mapping directly to an OS thread, it now goes through an extra layer:
Task → Virtual Thread → Carrier Thread (real OS thread)The clearest mental model here is a horse and rider. The OS thread is the horse — a real, limited, expensive resource. The virtual thread is the rider.
- The rider mounts a horse to do actual work.
- The moment the task hits I/O — a database call,
Thread.sleep, a network request — the rider dismounts. The horse is now free. - That same horse immediately picks up a different rider and keeps working.
- When the original task’s I/O finishes, its rider remounts — not necessarily the same horse, just whichever one’s free — and continues.
A small pool of maybe 16 carrier threads can serve millions of virtual threads this way, because at any given moment, almost none of them are actually doing CPU work — they’re waiting, and waiting doesn’t need a dedicated OS thread anymore.
Seeing It in Code
Platform threads, the old way:
for (int i = 0; i < 1_000; i++) { Thread thread = new Thread(() -> { try { Thread.sleep(5000); // This blocks the real OS thread } catch (InterruptedException e) { e.printStackTrace(); } }); thread.start();}Try scaling this to 20,000 threads and you won’t get graceful degradation — you’ll get a crash. Each platform thread reserves its own OS-level stack, and eventually the JVM throws OutOfMemoryError before your application does anything useful.
Virtual threads, the Java 21 way:
for (int i = 0; i < 1_000_000; i++) { Thread thread = Thread.ofVirtual().start(() -> { try { Thread.sleep(5000); // Unmounts the virtual thread, frees the carrier } catch (InterruptedException e) { e.printStackTrace(); } });}Same logic, same Thread.sleep, same try-catch. The only real change is Thread.ofVirtual(). And the results aren’t subtle:
| Thread count | Platform threads | Virtual threads |
|---|---|---|
| 20,000 | ❌ OutOfMemoryError | ✅ Runs fine |
| 100,000 | Not viable | ✅ Runs effortlessly |
| 1,000,000 | Not viable | ✅ Runs smoothly on a laptop |
That last row is worth sitting with. A million concurrent tasks, on a single JVM, on ordinary hardware — because the actual bottleneck (OS threads) has been decoupled from the thing you’re scaling (concurrent tasks).
Bringing This Into a Real Spring Boot Application
This is where the theory stops being academic. Spring Boot 3.2+ (running on Java 21) added first-class support for virtual threads, and turning it on is almost embarrassingly simple:
spring: threads: virtual: enabled: trueThat one flag changes how the embedded servlet container (Tomcat, by default) handles incoming requests. Instead of pulling a thread from a bounded platform-thread pool — typically capped around 200 threads by default in Tomcat — every incoming HTTP request gets its own cheap, disposable virtual thread.
What this changes in practice, for a typical Spring Boot service:
A standard Spring Boot controller calling a repository, then an external API, looks like this — and notice, nothing about the code itself changes:
@RestController@RequestMapping("/orders")public class OrderController {
private final OrderRepository orderRepository; private final PaymentClient paymentClient;
public OrderController(OrderRepository orderRepository, PaymentClient paymentClient) { this.orderRepository = orderRepository; this.paymentClient = paymentClient; }
@GetMapping("/{id}") public OrderResponse getOrder(@PathVariable Long id) { Order order = orderRepository.findById(id) // JDBC call — blocking I/O .orElseThrow(() -> new OrderNotFoundException(id)); PaymentStatus status = paymentClient.getStatus(order.getPaymentId()); // REST call — blocking I/O return new OrderResponse(order, status); }}Under the old platform-thread model, each of those two blocking calls (findById, getStatus) ties up a real OS thread from Tomcat’s pool for the entire duration of the call. With only ~200 threads available, roughly 200 concurrent requests is close to the practical ceiling before requests start queuing — even though the CPU itself is nearly idle, just waiting on the database and the payment service.
Flip on virtual threads, and the exact same code — no rewrite, no reactive types, no Mono/Flux — now unmounts the underlying carrier thread every time findById or getStatus blocks. The carrier thread goes and serves another request in the meantime. Your Spring Boot app can now comfortably hold tens of thousands of in-flight requests using a tiny handful of actual OS threads underneath.
This is the concrete, measurable version of everything covered above: same code, same architecture, order-of-magnitude more concurrent capacity.
How This Actually Cuts Server Costs
This is the part that matters to whoever signs off on your cloud bill.
Under the traditional model, when a Spring Boot service starts queuing requests under load, the standard response is to scale horizontally — add more application server instances behind a load balancer, so more platform-thread pools are available in aggregate. Each new instance is a new EC2/VM/container, each with its own memory footprint, its own JVM startup overhead, its own licensing and monitoring cost, and its own line item on the infrastructure bill.
Virtual threads attack the actual bottleneck instead of paying around it. Since the ceiling was never CPU capacity — it was the number of platform threads available to hold open, mostly-idle connections — giving each request a cheap virtual thread means a single instance can absorb far more concurrent load before it needs a sibling instance at all.
In concrete terms, teams migrating I/O-heavy Spring Boot services to virtual threads have reported handling the same peak traffic with significantly fewer running instances — because the constraint that forced horizontal scaling (thread pool exhaustion) simply isn’t the bottleneck anymore. You’re not paying for idle CPU cycles spread across five servers when the real problem was 200 threads being tied up waiting on a database.
This doesn’t mean horizontal scaling becomes pointless — it’s still essential for genuine CPU-bound scaling and for redundancy/high availability. But the point at which you’re forced into it purely to escape thread exhaustion moves dramatically further out, and in many I/O-heavy services, it stops being a cost-driven necessity altogether.
Horizontal Scaling vs. Vertical Scaling — What They Actually Mean
Since this keeps coming up, it’s worth being precise about the two terms, because they solve different problems.
Vertical scaling (“scaling up”) means making a single server more powerful — more CPU cores, more RAM, faster disks — while keeping the number of servers the same. It’s the equivalent of upgrading one worker’s desk to be bigger and faster instead of hiring more workers.
- Pros: No architectural changes, no load-balancing complexity, simpler to reason about.
- Cons: There’s a hard ceiling — eventually you can’t buy a bigger machine — and it’s a single point of failure; if that one beefed-up server goes down, everything goes down with it.
Horizontal scaling (“scaling out”) means adding more servers running the same application, with a load balancer distributing traffic across them. It’s the equivalent of hiring more workers instead of upgrading one worker’s desk.
- Pros: Effectively unlimited ceiling (add as many instances as needed), better fault tolerance — one instance failing doesn’t take the whole system down.
- Cons: More infrastructure to manage, more cost per unit of “headroom,” and it introduces distributed-systems complexity (session handling, service discovery, consistent configuration across instances).
Historically, I/O-heavy Java services scaled horizontally specifically because of the platform-thread ceiling covered earlier — not because they genuinely needed more compute power, but because they needed more threads, and the only way to get more threads was more machines. Virtual threads decouple “more concurrent capacity” from “more machines,” which means a lot of horizontal scaling that used to be a thread-count workaround becomes unnecessary — leaving horizontal scaling to do the job it’s actually meant for: fault tolerance and genuine compute growth, not compensating for a threading limitation.
So… Is Reactive Programming Dead Now?
This is the question every team using WebFlux, RxJava, or CompletableFuture chains is asking. The honest answer: it depends on why you adopted reactive in the first place.
- If your team went reactive purely to get non-blocking I/O — to avoid tying up threads while waiting on a database or API — virtual threads now give you that same benefit with plain, sequential code. You may not need the reactive machinery anymore.
- If your team relies on reactive streams for backpressure — controlling the rate at which a fast producer overwhelms a slow consumer — virtual threads don’t solve that. That capability is still squarely in reactive-programming territory.
There’s a real, practical case for retiring reactive code in the first bucket. CompletableFuture chains read cleanly for the happy path, but error handling tends to nest into something genuinely painful to maintain:
// The old asynchronous way — clean happy path, messy error handlingCompletableFuture.supplyAsync(() -> fetchUserFromDB(userId)) .thenCompose(user -> fetchOrdersFromAPIAsync(user)) .thenAccept(orders -> renderDashboard(orders)) .exceptionally(ex -> { logger.error("Failed to load dashboard", ex); return null; });With virtual threads, you get the same non-blocking behavior with none of the callback gymnastics:
// The virtual-thread way — plain, sequential, and just as scalabletry { String user = fetchUserFromDB(userId); // I/O — unmounts the virtual thread String orders = fetchOrdersFromAPI(user); // I/O — unmounts again renderDashboard(user, orders);} catch (SQLException | IOException e) { logger.error("Failed to load dashboard", e);}Ordinary try-catch. No .thenCompose(), no nested lambdas, no stack traces that point at framework internals instead of your code. Underneath, Java is still doing the mounting and unmounting — you just don’t have to think about it anymore.
Where This Actually Helps (and Where It Doesn’t)
Use virtual threads for:
- Web servers and REST APIs handling high request volume (Spring Boot controllers included)
- Microservices making frequent downstream calls — databases, other services, external APIs
- Any workload dominated by waiting — database queries, network I/O, file access
Don’t bother with virtual threads for:
- CPU-bound work — matrix multiplication, video encoding, cryptographic hashing
- Anything without I/O blocking in the first place
The reason is simple: virtual threads earn their scalability by unmounting during I/O waits. If there’s no waiting — just continuous computation — there’s nothing to unmount from, and a virtual thread behaves no better than a platform thread. In fact, for pure CPU-bound work, stick with a fixed-size platform thread pool sized to your core count.
One more caveat worth knowing before you flip the switch in production: synchronized blocks can pin a virtual thread to its carrier, preventing the unmount from happening. If your Spring Boot code leans heavily on synchronized methods around I/O calls (some connection pools and legacy libraries do this internally), you may not get the full benefit until those are replaced with java.util.concurrent.locks.ReentrantLock or updated library versions that are virtual-thread-aware.
package com.virtualthreads;
public class VirtualThreads { static void main(String[] args) { long starts = System.currentTimeMillis();
var MAX = 10_00_000; for (var i = 0;i< MAX;i++){ new Thread(VirtualThreads::doWork).start();// Thread.startVirtualThread(VirtualThreads::doWork); }
try { Thread.sleep(5000); } catch (InterruptedException _){ } System.out.println("Completed"); long end = System.currentTimeMillis(); System.out.println("Time taken for executing in milli seconds: "+(end-starts) ); System.out.println("Time taken for executing in seconds : "+((end-starts)/1000) ); }
public static void doWork() { try { Thread.sleep(3000); } catch (InterruptedException _){
} }}Output:/*Without Virtual Thread - MAX = 100_000DoneTime taken for executing in milli seconds: 47542Time taken for executing in seconds : 47
With Virtual Thread -MAX = 100_000DoneTime taken for executing in milli seconds: 5156Time taken for executing in seconds : 5
------------------```Without Virtual Thread - MAX = 10_00_000
DoneTime taken for executing in milli seconds: 416654Time taken for executing in seconds : 416
With Virtual Thread -MAX = 10_00_000
DoneTime taken for executing in milli seconds: 6101Time taken for executing in seconds : 6*/