Virtual Threads in Spring Boot 4

Virtual Threads in Spring Boot 4 benchmark — about 6x throughput from one flag, from roughly 2,200 to 13,700 requests per second

Estimated reading time: ~14 minutes. All code, raw CSVs and the chart script are in the companion repository — run the benchmark on your own hardware and tell us what you get.

In this article we take one realistic, blocking Spring Boot 4 service, flip a single configuration flag to turn on virtual threads, and measure — carefully — exactly what changes. No framework-wide shoot-out, no synthetic Thread.sleep() toy. One service that does what real services do: a database query through HikariCP and a downstream HTTP call. We change one line, we run the same load against it, and we look at the numbers.

Throughput and p99 latency versus concurrency: platform threads plateau near 2,200 req/s while virtual threads scale to about 13,700 req/s
Throughput and p99 latency versus concurrency, on identical code. The only difference between the two pairs of lines is spring.threads.virtual.enabled

At 2,500 concurrent users the platform-thread version of the service tops out at about 2,200 requests per second, with p99 latency already climbing past a second. The virtual-thread version — same code, same hardware, same database — sustains about 13,700 requests per second at the same load, with p99 still under a second. That is roughly a 6× improvement in throughput from a one-line change.

But the more useful finding is the one nobody leads with, and it is the reason this article exists:

Virtual threads did not make our service faster. They moved its bottleneck. Once the Tomcat thread pool stopped being the limiter, the HikariCP connection pool became it — and if you don’t go and resize that pool, you leave most of the win on the table.

We’ll prove both claims with real data. We’ll also settle a piece of advice that has gone stale: the old rule that you must “avoid synchronized on hot paths because it pins the carrier thread.” As of Java 24 (and the Java 25 LTS we run here), that is no longer true. Let’s start with why any of this works.

The mental model, in sixty seconds

A traditional platform thread is a thin wrapper over an operating-system thread. It is expensive — a megabyte or so of stack, scheduled by the OS — so you can only afford a few thousand of them. In a classic thread-per-request server like Tomcat, that ceiling is explicit: the default pool is 200 threads. Request number 201 waits in a queue until one of those 200 frees up.

A virtual thread is managed by the JVM, not the OS. It is cheap enough that you can have millions. When a virtual thread hits a blocking call — a JDBC query, an HTTP request, anything that waits on I/O — it unmounts from its underlying carrier (platform) thread and parks. The carrier is freed instantly to run a different virtual thread. When the I/O completes, the parked virtual thread mounts onto any available carrier and continues.

How a virtual thread mounts a carrier thread and unmounts on blocking I/O, freeing the carrier to run another virtual thread

The carrier pool is a ForkJoinPool sized, by default, to the number of CPU cores. That is the whole trick: a blocked virtual thread costs almost nothing, because the carrier doesn’t wait with it. Ten thousand requests can be “in flight,” each parked on its own virtual thread waiting for the database, while a handful of carrier threads stay busy doing actual work.

Note: this only helps work that waits. If your request is busy computing — crunching numbers, doing CPU-bound work — there is nothing to unmount, and virtual threads buy you nothing. We’ll come back to that in the gotchas.

So why does the platform-thread version hit a wall? Because of where the queue forms.

Platform threads queue behind Tomcat's fixed 200-thread pool while virtual threads give each request its own thread with no web-tier queue

With platform threads, request 201 through request 5,000 all pile up behind those 200 threads. Throughput flattens and latency climbs, regardless of how idle the CPU is. With virtual threads, every request gets its own thread immediately — there is no queue at the web tier. The work still has to happen, but the web server is no longer the thing holding everyone up.

That’s the theory. Now let’s build something to test it.

The service we’re going to rewrite

We need a service that behaves like a real one, because the whole point is to measure realistic resource pools, not a microbenchmark artifact.

Our system under test is a single Spring Boot 4.0.7 endpoint, GET /api/order-summary/{id}, that does two things every web service does:

  1. A PostgreSQL query through JDBC and HikariCP — a primary-key lookup on an orders table (one million rows, seeded) joined to customers.
  2. A blocking downstream HTTP call to another service, via Spring’s RestClient, which returns after a simulated 100 ms — standing in for the payment gateway, inventory service, or third-party API that real endpoints depend on.

It then combines the two results into a JSON response. Nothing exotic; this is the shape of a huge amount of production code.

public OrderSummary getOrderSummary(long orderId) {
    // 1. A real JDBC query through HikariCP (sub-millisecond PK lookup + join)
    OrderRecord order = orderRepository.findWithCustomer(orderId);
    // 2. A blocking downstream HTTP call (~100 ms simulated latency)
    EnrichmentResponse enrichment = restClient.get()
            .uri("/enrich/{id}", order.customerId())
            .retrieve()
            .body(EnrichmentResponse.class);
    return OrderSummary.combine(order, enrichment);
}

Note: one small Spring Boot 4 wrinkle worth flagging, because it will bite you. Boot 4 changed how the RestClient.Builder is auto-configured, so we wire the underlying client explicitly (a RestClient backed by the JDK HttpClient) rather than relying on the older auto-config. If you’re migrating from Boot 3 and your RestClient bean suddenly isn’t what you expected, that’s why.

The application code above is identical across every run in this article. The only things that change between runs are configuration: whether virtual threads are on, and how big the connection pool is.

Run this service with platform threads and push the load up, and you see exactly the failure mode the mental model predicts: throughput climbs to a plateau around 2,000–2,600 requests per second, p99 latency rises into multiple seconds, and — the tell — the CPU is nowhere near saturated. The limiter isn’t the machine. It’s the 200-thread pool.

The rewrite (this is the whole change)

Here is the entire code change required to put the service on virtual threads:

spring.threads.virtual.enabled=true

That’s it. One property.

When you set this, Spring Boot configures Tomcat to handle each incoming request on a virtual thread instead of borrowing one from the fixed worker pool. It also points Spring’s own managed executors — @Async task execution, scheduled tasks, and similar — at virtual-thread-based executors. Your controllers, services, and repositories don’t change. Your blocking JDBC and RestClient calls don’t change. You write the same straight-line, easy-to-read, easy-to-debug blocking code you always have; the runtime simply stops paying the thread-per-request tax for it.

This is the part that makes virtual threads compelling in a way that reactive programming never quite managed: there’s no rewrite. Reactive/WebFlux gets you similar scalability, but it asks you to turn your whole call chain into non-blocking Mono/Flux pipelines, restructure your error handling, and give up step-through debugging. Virtual threads ask you to set a flag.

So we set the flag, and we measure. Carefully — because a sloppy benchmark is worse than none.

How we measured (the credibility section)

If you’re going to believe the numbers, you need to know how they were produced, so here is the rig in full. (The companion repository has the one-command reproduction; this is the summary.)

Benchmark test rig: a single host running the Spring Boot app, PostgreSQL, and load generator each pinned to separate CPU cores over loopback
  • One host, no network. Everything runs on a single Intel i9-9900K (8 physical cores / 16 logical), 32 GB RAM, on Linux (WSL2). The app, PostgreSQL, and the load generator each get their own pinned set of CPU cores (via taskset) so they never fight each other for time. Traffic flows over loopback. This deliberately removes the network as a variable — wireless jitter or a shared switch would route straight into every latency percentile and wreck the comparison.
  • Pinned versions. Temurin 25.0.3 LTS (Java 25, which includes the JEP 491 pinning fix), Spring Boot 4.0.7, Tomcat 11, HikariCP, PostgreSQL 16.
  • The load generator is k6, driving a closed model (ramping-vus): concurrency equals the number of virtual users, which is how real connection pressure behaves. We discard a warm-up window and measure only the steady-state hold.
  • The downstream is a small non-blocking WebFlux/Netty stub that returns after 100 ms — fast enough that it never becomes the bottleneck itself.
  • The critical check. On every single run we log per-tier CPU and prove that the load generator and the database were not saturated. A saturated load generator masquerading as “the app topping out” is the single most common way benchmarks lie, and we went out of our way to rule it out.

Two honest caveats, stated plainly. First, single-host loopback means the absolute latency numbers are optimistic — there’s no real network hop. The claims here are about the relative difference between platform and virtual threads, and the bottleneck shift, both of which hold regardless of loopback. Second, the figures below are the median of 3 repetitions per data point. Three reps is enough to trust effects this large, but it’s fewer than the five we’d want for tight variance bars, so treat the small wiggles as noise and the big gaps as signal.

With that out of the way — the results.

The benchmark

Look again at the hero chart, now that you know what you’re seeing.

Throughput and p99 latency versus concurrency, platform versus virtual threads in Spring Boot 4

At low concurrency (100 users) the two are tied at about 1,050 req/s — not because they’re equal, but because with only 100 users each waiting ~100 ms on the downstream call, neither version is being asked for more than about a thousand requests a second. There’s no pressure yet.

Push the load up and they diverge hard. The platform-thread service climbs to its plateau — roughly 2,000 to 2,600 req/s — and stays there no matter how many more users arrive. Those extra users don’t get served faster; they get queued, which is why the orange p99 latency line marches from over a second at moderate load up past 6.5 seconds at 10,000 users. That is the 200-thread wall, drawn in data.

The virtual-thread service keeps scaling. It passes 5,000 req/s at 500 users, 10,000 req/s at 1,000 users, and peaks near 13,700 req/s at 2,500 users — about 6.1× the platform plateau at the same concurrency — while keeping p99 latency sub-second (a median around 690 ms, against the platform version’s ~2.8 seconds at the same load). The app’s CPU finally becomes the thing doing the limiting, sitting around 80% instead of idling.

Note the right-hand edge: at 10,000 users the virtual line drops to about 10,000 req/s and p99 rises past 3 seconds. That’s not a measurement error — it’s a real closed-model overload cliff. Past a certain point you’ve simply oversubscribed the machine, and throughput degrades. It’s an honest data point, and worth knowing your own cliff exists; it just isn’t your steady-state operating number.

So: 6× more throughput, a fraction of the tail latency, from one flag. You could stop reading here and you’d already have a result worth shipping. But if you stop here, you’ll misconfigure it — because the win has a catch.

“Your bottleneck just moved”

Here’s the thing the throughput chart doesn’t show you. When virtual threads removed the Tomcat thread-pool ceiling, they didn’t remove the ceiling. They revealed the next one down.

Request pipeline showing the bottleneck moving from the Tomcat thread pool to the HikariCP connection pool when virtual threads are enabled

Every request in our service needs a database connection. With platform threads, you never noticed the connection pool, because the 200-thread wall meant you never had more than 200 requests trying to get one at the same time. Remove that wall, and suddenly thousands of virtual threads are all reaching for the connection pool at once. Now it is the gate everyone queues behind.

To show this cleanly, we ran a second experiment. We changed the service to a deliberately demanding pattern — holding the database connection across the downstream HTTP call, the way a transaction spanning an external call would — so that the connection is checked out for the full ~100 ms. That makes the connection pool a hard throughput ceiling, and then we swept the HikariCP pool size from 50 up to 800, at a fixed 2,000 concurrent users, on both thread models.

Throughput versus HikariCP pool size: platform threads flatline at the 200-thread wall while virtual threads scale with the pool to about 8,370 req/s

This is the whole story in one chart:

  • At small pools (50, 100), both models are pool-limited and essentially identical — throughput is just pool size divided by the 100 ms hold time. The thread model is irrelevant when the pool is the binding constraint.
  • At a pool of 200, platform threads hit their own wall (200 threads can’t hold more than 200 connections) and flatline at about 2,100 req/s. Growing the pool beyond 200 does nothing for them — they have no threads to use the extra connections.
  • Virtual threads keep riding the pool up: ~4,400 req/s at a pool of 400, ~8,370 req/s at a pool of 800 — nearly 4× the platform number, purely from connection-pool size.

And here’s where the queue went. We logged HikariCP’s pending-connection count throughout. With virtual threads, the pending queue forms at the pool — hundreds to nearly two thousand requests waiting for a connection. With platform threads, pending stays near zero, because the requests are queued earlier, back at the thread pool. Same backlog; it just re-forms at the next narrow point downstream. That’s the insight: virtual threads don’t make you faster, they move your bottleneck — so go find the new one and size it.

One more warning the data gives us, though, because “size it” is not the same as “make it huge.” Growing the pool means growing the number of live connections PostgreSQL must service. Even in our run, at a pool of 800 the database CPU was climbing toward the app’s. Your real database has a hard ceiling on connections (and a sweet spot well below it); past that, more pool is slower, not faster. The right move is to raise the pool to match your new concurrency and your database’s capacity — and no further. Virtual threads make the connection pool your tuning dial. They don’t repeal the laws of your database.

The pinning section: why your old advice is out of date

There’s a piece of virtual-threads folklore you’ve almost certainly read: “avoid synchronized blocks on hot paths, because they pin the carrier thread.”

It used to be true. Before Java 24, if a virtual thread was inside a synchronized block or method and hit a blocking call, it could not unmount — it stayed stuck on its carrier thread, which is called pinning. Pin enough carriers and you starve the pool, and your shiny virtual-thread scalability quietly collapses back to platform-thread behaviour. So the advice was to rip synchronized out of hot paths and replace it with ReentrantLock.

Pinning timeline: Java 21 synchronized pins the carrier, JEP 491 in Java 24 removes it, inherited by Java 25 LTS, plus what still pins

JEP 491, delivered in Java 24 (March 2025) and inherited by the Java 25 LTS we run here, changed this. Virtual threads can now release their carrier even inside synchronized. The single most-repeated piece of tuning advice for virtual threads has, for current JDKs, expired.

What still pins, and is worth auditing:

  • Native methods (JNI) — calls into native code.
  • Foreign Function & Memory API (FFM) downcalls — the modern successor to JNI.
  • A handful of older libraries running on pre-24 JDKs.

So the new rule isn’t “avoid synchronized.” It’s “you’re probably fine — but confirm it on your own stack rather than trusting either the old advice or this article.” The check takes minutes, and it’s worth doing because the answer depends on your exact JDK and libraries. Run load through a synchronized-heavy path with -Djdk.tracePinnedThreads, or record a JDK Flight Recorder profile and count jdk.VirtualThreadPinned events. On a current JDK, with synchronized as your only suspect, you should see that count stay at zero — and if it doesn’t, the culprit is almost certainly one of the native or FFM cases above, not the synchronized everyone spent years warning you about.

Five gotchas before you ship

Turning the flag on in a demo is easy. Running virtual threads in production without surprises takes a short checklist.

Five virtual threads gotchas: don't pool them, resize HikariCP, ThreadLocal cost, CPU-bound work gets nothing, audit native and FFM pinning
  1. Don’t pool virtual threads. They’re cheap and disposable — create one per task and let it die. Putting them behind a fixed-size executor re-introduces exactly the ceiling you just removed. Use Executors.newVirtualThreadPerTaskExecutor(), never a bounded pool.
  2. Resize HikariCP — the pool is your new ceiling. This is the whole second half of this article. Once Tomcat stops being the limiter, the connection pool becomes it. Raise it to match your concurrency and your database’s capacity.
  3. ThreadLocal cost scales with thread count. A bit of per-thread state is harmless across 200 platform threads. Across hundreds of thousands of virtual threads it’s real memory. Audit your ThreadLocal usage; prefer scoped values where you can.
  4. CPU-bound work gets nothing. Virtual threads help work that waits. If a request is busy computing, there’s nothing to unmount and no benefit — you may even add a little overhead.
  5. Audit native / FFM pinning and legacy libraries. JEP 491 fixed synchronized, but JNI and the Foreign Function & Memory API still pin the carrier, and old libraries on old JDKs may too. Measure, don’t assume.

Should you flip the switch?

Pulling it all together into a decision you can actually act on:

Decision flowchart for enabling virtual threads in Spring Boot 4 based on whether work is I/O-bound, blocking, and high-concurrency
  • Is the work I/O-bound — waiting on a database, an HTTP call, the disk? If no, it’s CPU-bound and virtual threads won’t help; there’s nothing to unmount.
  • Is it blocking, thread-per-request code, not already reactive? If you’ve already invested in WebFlux, you’ve solved this a different way — don’t rewrite to chase the same scalability twice.
  • Do you run high concurrency — hundreds or thousands of simultaneous requests? If not, it’s low-risk to enable, but the payoff shows up under load you may not currently have.

If you answered yes, yes, yes — a blocking, I/O-bound Spring service under real concurrency — then enable it, and resize your pools. That is the sweet spot, and it’s an enormous slice of the Spring applications running in the world today. Set the flag, raise HikariCP, don’t pool the virtual threads, and you get most of the scalability of a reactive rewrite for a fraction of the effort.

Reproduce this

Every number in this article comes from a public, one-command-reproducible benchmark. The repository has the Spring Boot service, the load scripts, the SQL, the core-pinning harness, the raw matrix.csv, and the exact make_charts.py that produced the charts above. The machine specs and honest caveats are in the README.

We’d genuinely like you to run it on your own hardware — a cloud instance, a bigger box, an ARM machine — and tell us what you get. Different CPU counts change the carrier-pool size and move these curves around, and the bottleneck-shift story is worth confirming on a database that isn’t sharing a host. If you reproduce it, tell us — we’ll happily link the follow-ups.

One flag turned a 2,200 req/s service into a 13,700 req/s service. The catch was that the win was only half-collected until we went and resized the resource the flag exposed. That’s the real lesson of virtual threads, and it’s the one to take into your own codebase: they don’t make you faster — they show you where you were slow.


Built and measured by Tucanoo Solutions. Versions: Temurin 25.0.3 LTS · Spring Boot 4.0.7 · Tomcat 11 · HikariCP · PostgreSQL 16 · k6. Single-host, core-pinned, loopback. Median of 3 repetitions per data point.

David Brown founded Tucanoo Solutions in 2011 and has written software in a professional capacity for over 30 years, back to the Java/J2EE days of Sun Microsystems. He builds cloud and web applications for clients in legal tech, government and beyond, including several years consulting to the United Nations on customs and transport systems. He writes these tutorials himself, with the full code on GitHub.

Leave a Reply

Your email address will not be published. Required fields are marked *