Enabling virtual threads in Spring Boot (spring.threads.virtual.enabled=true) makes
server.tomcat.threads.max inert. Your concurrency gate moves from Tomcat's 200-thread worker pool
to server.tomcat.max-connections, default 8192. Throughput barely changes, but requests that used
to queue harmlessly at the connector now fail late, deep in your app. The fix is a deliberate limit,
not the flag.
Your thread pool had a second job you never gave it: admission control.
spring.threads.virtual.enabled=true deletes that job without telling you. Tomcat's gate quietly
moves from 200 to 8192.
Same throughput. New failure mode. Here's the field guide, measured.
The precise version, since "same throughput" is doing some work in that sentence: goodput stayed roughly unchanged (25,858 requests OK under virtual threads versus 27,319 on the platform-thread baseline, within about 5%), but around 15% of requests now failed late. Not slower. Failed. Same load, same code, one YAML line different.
I wired three Spring Boot profiles that differ by a few lines of configuration, pointed the same
load spike at each, and measured what got through and what fell over. Same app, same Postgres, same
10-connection HikariCP pool. Only the concurrency
wiring changes between them. Everything below is either a measured number from that repo or a quoted
source, and every number is labelled measured or documented so you know which is which. It's the
same one-command, public-repo discipline I used for the
Spring OAuth2 series: mise run demo builds the app, starts Postgres,
runs all four load profiles, and renders the figures you'll see below.1
A quick definition, because the whole post turns on it. Admission control is the decision of how many requests you let do work at once, and what happens to the ones you don't. Every server has it somewhere. The trouble with virtual threads isn't that they break admission control. It's that they move it, and most of the advice you'll find hasn't caught up.
The advice is staler than the feature
Search "Spring Boot virtual threads" today and the top results still open with a warning about pinning. That warning was real. It's now two JDK releases out of date, and leading with it tells you the page was written for a runtime you're probably not running.
A virtual thread is a lightweight thread scheduled by the JVM instead of the OS, so a blocking
call parks cheaply and you can have millions of them. They ride on a small pool of real OS threads
called carriers. Pinning is when a virtual thread gets stuck to its carrier and can't unmount,
so for the duration of the pin it stops being cheap. In JDK 21, when virtual threads went final
under JEP 444, a synchronized block would pin. That's the origin
of every "careful, virtual threads and synchronized don't mix" post.
Then JEP 491 landed in JDK 24 and fixed it. synchronized no longer
pins the carrier. A few things still do: blocking native calls through JNI or the Foreign Function
interface, and class initializers. But the common case the tutorials warn about is gone. The
diagnostic advice is stale too: the old -Djdk.tracePinnedThreads flag was removed alongside
JEP 491. The tool now is the JFR event jdk.VirtualThreadPinned. The companion repo has a
mise run pindemo task that forces a real pin through a blocking class initializer and prints the
event, so you can see what a genuine pin looks like before you go hunting for one that isn't there.
Here's why this matters beyond trivia. I'll show you a run where 15% of requests fail under virtual
threads. The stale advice hands you a ready-made villain: it must be pinning. It wasn't. During that
run, JFR recorded exactly 0 jdk.VirtualThreadPinned events.1 If you chase the pinning ghost,
you'll rewrite synchronized blocks that were never the problem and the failures will still be
there. The thing that actually bit is where your concurrency limit went.
What actually limits concurrency once virtual threads are on?
On stock Tomcat, before you touch anything, the limit is the worker pool:
server.tomcat.threads.max, default 200. Two hundred requests do work at once. Request 201 waits in
the connector until a worker frees up. That pool was doing two jobs, and you only ever thought about
one of them. Job one: run request handlers. Job two, the invisible one: cap how many requests are in
flight. The pool was your admission control, and it was set to 200 by accident of the default.
The platform baseline shows it working. I threw 7,000 concurrent workers at the 200-thread profile:
| Wiring | In-flight (measured max) | Connections accepted | Outcome |
|---|---|---|---|
| platform | 200 | 7,001 | 27,319 requests, all 200 OK, zero errors |
Tomcat accepted about 7,000 connections but only ever ran 200 at a time. The other ~6,800 sat queued
in the connector, waiting their turn, bounded by max-connections and completely harmless. Everyone
got served. Nothing failed. That queue is doing exactly what a queue is for.
Now flip the one line. The platform profile just pins the worker pool to Boot's default, made
explicit so the before-picture is on the page:
# Boot default worker pool, made explicit for the article's before-picture.
server:
tomcat:
threads:
max: 200The vt profile throws that away and turns on virtual threads instead. This is the entire
difference between the two:
# Virtual threads, nothing else. server.tomcat.threads.max is now inert (per Boot docs);
# the remaining concurrency gate is server.tomcat.max-connections (default 8192).
spring:
threads:
virtual:
enabled: trueThe moment virtual threads are on, server.tomcat.threads.max stops meaning anything. This isn't a
side effect I inferred. It's in Spring Boot's own configuration appendix, on the threads.max
property, verbatim:
Maximum amount of worker threads. Doesn't have an effect if virtual threads are enabled.2
So the worker pool is no longer the gate. It didn't get bigger. It got deleted as a limit. The next
constraint down the stack takes over, and on Tomcat that's server.tomcat.max-connections, which
defaults to 8192.2 (Not 10000, which is the number half the blog posts cite. Check the
source.) Your effective concurrency limit just went from 200 to 8192 without a single line in your
config file changing, and nothing logged a word about it.
Your door is still there. It just stopped stopping things.
The queue didn't disappear. It moved.
Same 7,000-worker spike, now against the vt profile. Here's what got through:
| Wiring | In-flight (measured max) | Connections accepted | Outcome |
|---|---|---|---|
| platform | 200 | 7,001 | 27,319 OK, zero errors |
| vt | 7,000 | 7,001 | 30,579 total: 25,858 OK, 4,721 × HTTP 500 (~15%) |
Read the in-flight column, because that's the whole story. Both wirings accepted about the same 7,000 connections. Under platform threads, 200 of them did work at a time. Under virtual threads, all 7,000 did. The queue that used to sit in the connector, invisible and bounded, moved inside the application and landed on the first scarce resource it found: the database connection pool.
That pool holds 10 connections. So roughly 7,000 requests were now contending for 10 connections. That is 700 times the pool size (and 35× what the old 200-thread gate would have allowed through). Each request that couldn't get a connection waited on Hikari's borrow timeout, which I'd set to 30 seconds. When that timer expired, the request didn't slow down. It threw:
org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection
Caused by: java.sql.SQLTransientConnectionException: HikariPool-1 -
Connection is not available, request timed out after 30000ms
(total=10, active=10, idle=0, waiting=...)4,721 of them, clustered on the 30-second line. That's the surprise worth sitting with: the throughput barely moved, so a dashboard watching requests-per-second would show two nearly identical runs. The difference only appears if you're watching the failure rate and the latency distribution.
One honesty beat before I let this land. The vt in-flight count topped out at 7,000, and that
number is not a limit the server imposed. It's the number of workers in my load driver. The server
would happily have admitted more. The only thing capping concurrency in that run was my client
running out of threads to throw. Hold that thought, because the next run removes that excuse.
Where does the pileup actually hurt? Look at the latency, not the counts:
Now, the tempting diagnosis. Virtual threads plus a database plus 15% failures: it must be pinning,
right? The JFR recording from that exact run says no. Zero jdk.VirtualThreadPinned events.1
Hikari's getConnection wait parks the virtual thread through its internal ConcurrentBag handoff,
which is virtual-thread-friendly waiting; no carrier gets pinned. In fact HikariCP's own "add
virtual thread support" pull request, #2055, was closed unmerged because the maintainer judged the
synchronized blocks in question don't see real contention; the thread wound down for good once JEP
491 was confirmed for JDK 24.3
So the pool didn't break under virtual threads. It did precisely its job: hand out 10 connections, make everyone else wait up to 30 seconds, then throw a clean exception. What changed is how many requests were allowed to line up for it. The failure is admission control. It only looks like a connection-pool problem because the pool is where the missing limit finally became visible.
The connector ceiling is a higher wall, not a safety net
You might reasonably think: fine, there's still an 8,192 ceiling up there, so the connector will
save me. It won't, and the ceiling run is where that hope dies. This time I raised the load driver
to 9,500 workers, more than the 8,192 connection limit, and pointed it at the plain vt profile
again:
| Wiring | In-flight (measured max) | Connections | Outcome |
|---|---|---|---|
| vt-ceiling (9,500 workers) | 8,192 | plateau exactly 8,192 | 49,785 total: 25,384 OK, 11,379 × late 500, 13,022 connect-timeouts refused at the gate |
Two things happened at once, and it's worth being precise about which number is which. In-flight and
connections both plateaued at exactly 8,192 at any given instant: that's max-connections doing its
job as a ceiling. Over the whole run, 13,022 connection attempts were refused at that gate (the load
driver saw them as connect-timeouts), and 11,379 requests that did get admitted then failed late at
Hikari, same 30-second timeout as before. The instantaneous plateau and the cumulative totals are
different measurements; don't blur them.
That's the payoff. The connector ceiling didn't protect the app. It just gave the failures two places to happen instead of one: refused at the door, or admitted and then drowned at the pool. A ceiling this high isn't a safety net. It's a higher wall, and the pileup happens on the other side of it just the same.
Put a real limit in front of the scarce resource instead, and the same overload story ends with zero late failures. I'll build that limit next. The gate caps the count, not the pileup. The only limit that helped was the deliberate one.
Every gate, in one table
Before the fix, here's the whole map on one screen. This is the thing worth saving. Each cell is where your concurrency limit actually lives under each wiring:
| Layer | Platform threads | Virtual threads |
|---|---|---|
Tomcat worker pool (server.tomcat.threads.max) | 200, the accidental gate | inert ("Doesn't have an effect") |
Tomcat connections (server.tomcat.max-connections) | 8,192, absorbs the queue, rarely the limit | 8,192, the only gate left (measured plateau) |
Jetty (server.jetty.threads.max) | works | silently ignored on 3.4.11–3.5.10 and 4.0.0–4.0.2; fixed in 3.5.11 / 4.0.3 |
Hikari (maximum-pool-size) | 10, fed by ≤200 | 10, fed by ~7,000 (700×), fails late at 30 s |
| Deliberate app-level limit | the pool itself was it | Semaphore / @ConcurrencyLimit(limit, policy = REJECT): measured 0 late failures |
The left column is a system where the limit lives somewhere you can point to. The right column is a system where, unless you put a limit back on purpose, the only one left is a number you never chose.
The fix is a deliberate limit, not a bigger machine
The answer is to put admission control back where you can see it. Oracle's virtual-threads guide is
blunt about this: virtual threads aren't a scarce resource, so you should never pool them, and when
you need to limit concurrency you reach for "a construct designed specifically for that purpose: the
Semaphore class."4 A semaphore with 10 permits in front of the database work means at most
10 requests touch the pool at once, and the 7,000th request finds out immediately instead of 30
seconds later.
Spring Framework 7 wraps that pattern in an annotation, @ConcurrencyLimit, which its docs call
particularly useful with virtual threads precisely because there's no thread-pool limit in place
anymore.5 Here's the whole service in the vt-gated profile:
@Service
public class WorkService {
private final JdbcTemplate jdbc;
public WorkService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@ConcurrencyLimit(limit = 10, policy = ConcurrencyLimit.ThrottlePolicy.REJECT)
public long doWork() {
long t0 = System.nanoTime();
jdbc.execute("select pg_sleep(0.05)");
return (System.nanoTime() - t0) / 1_000_000;
}
}Now the same 7,000-worker spike that broke the plain vt profile, this time against vt-gated:
| Wiring | In-flight (measured max) | Outcome |
|---|---|---|
| vt-gated | 14 (held around 10) | 367,758 total: 19,313 OK, 348,431 fast 503s, 14 connect-timeouts, 0 late failures |
In-flight held around 10, exactly the limit. Zero late 500s; the only other failures were 14 client connect-timeouts at the connector, the same failure category the ceiling run counts 13,022 of. The 348,431 rejections came back as fast 503s, median around 12 ms and virtually all within a few hundred milliseconds, not 30-second timeouts. Nothing waited 30 seconds to fail. The failures moved from "admitted, then drowned" to "turned away at the door, instantly," which is the only humane way to shed load you can't serve.
I have to name the tradeoff, or I'd be selling you something. The gated profile's goodput, 19,313,
is about 30% below the platform baseline's 27,319. That's real, and it's because I set the limit
to 10 to make the mechanism visible, tighter than the 200 the platform pool allowed by accident. In
production you'd size the gate to what your downstream can actually take, not to the smallest number
that makes a good chart. The point of 10 here is that the limit is chosen and visible, not
that it's tuned.
Three gotchas the annotation won't warn you about
I hit all three building this. Each one is the kind of thing that looks like it works in a smoke test and doesn't under load.
First, the default policy blocks. Leave the policy off and @ConcurrencyLimit parks the caller and
waits for a permit, with no timeout knob to bail out. Under a 7,000-request pileup, blocking is just
the 30-second failure with extra steps. You have to pass
policy = ConcurrencyLimit.ThrottlePolicy.REJECT to fail fast, which is why it's spelled out on the
annotation above.
Second, and this one cost me an afternoon: the annotation does nothing on its own.
@ConcurrencyLimit is silently ignored unless something switches on the resilience infrastructure
with @EnableResilientMethods. Here's the entire config class that arms it, gated to the vt-gated
profile:
@Profile("vt-gated")
@Configuration(proxyBeanMethods = false)
@EnableResilientMethods
public class GatingConfig {
}Without that class, the annotation compiles, the app starts, and the limit just isn't there. Spring
Boot won't auto-configure it for you either. I checked whether that was an oversight: issue #46916
asked for exactly this auto-configuration and was closed as not-planned, with the maintainer's
reasoning that "we decided not to do this as it's a one-liner to enable the feature."6 So the
gate is code, not config. Here's the proof, the entire vt-gated YAML file:
# Virtual threads + a deliberate admission gate: the 'vt-gated' profile also activates
# GatingConfig (@EnableResilientMethods), which arms @ConcurrencyLimit on WorkService.
# Without that config class the annotation is silently ignored (Boot declined to
# auto-configure it; spring-boot issue #46916).
spring:
threads:
virtual:
enabled: trueIt's byte-identical to the vt profile apart from those comments. The difference between "no limit"
and "a real limit" isn't a property you can set. It's whether GatingConfig is on the classpath
with its profile active.
Third, a rejected call throws InvocationRejectedException, and if you do nothing that surfaces as
an HTTP 500, not the 503 you'd want. The clean 503 is not free. It comes from a hand-written
exception handler:
@RestControllerAdvice
public class ApiErrorHandler {
@ExceptionHandler(InvocationRejectedException.class)
public ResponseEntity<Map<String, String>> onRejected(InvocationRejectedException ex) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("error", "concurrency_limit_rejected"));
}
}Those 348,431 clean 503s in the table above are that handler doing its job. Skip it and your "graceful" rejection is indistinguishable, to a client or a load balancer, from the server falling over.
ThreadLocal breaks under virtual threads; scoped values replace it
There's a second thing that changes when you go all-in on virtual threads, and it's quieter than the
admission-control shift because it fails softly. ThreadLocal was doing two different jobs, and
virtual threads split them apart.
Job one was caching: a ThreadLocal holding an expensive-to-create object, reused across the many
requests that shared a pooled thread. Under a virtual-thread-per-request model that reuse is gone.
Each request gets its own fresh thread, so the "cache" has roughly a 0% hit rate and every request
re-pays the creation cost. This isn't primarily a memory leak, though it can look like one. It's a
caching strategy that quietly stopped caching.
Job two was context: carrying a request ID, a tenant, a security principal down a call chain without
threading it through every method signature. That job is real and it moves to
JEP 506 scoped values, final in JDK 25. A scoped value is an
immutable value bound to a dynamic scope: you set it, everything called inside that scope can read
it, and it's automatically gone when the scope exits. It's a better fit than ThreadLocal even
ignoring virtual threads, because it can't be mutated halfway down the stack and can't leak past its
scope. Where it gets interesting is sharing that context across threads you fork off, which is where
structured concurrency comes in, and where this post's sequel goes.
When not to reach for virtual threads alone
One grounding paragraph, because "virtual threads everywhere" is its own stale advice in the making.
Virtual threads remove the scarcity that made you write reactive or CompletableFuture chains
just to handle concurrency. If the only reason your code was reactive was "I can't afford to block a
thread per request," virtual threads let you write the straightforward blocking version and keep the
throughput. But reactive stacks still win where the problem is genuinely about flow, not thread
count: backpressure, streaming, and composing over unbounded event sources. A
Spring Cloud Gateway is reactive for exactly that
reason. So is a consumer draining a queue, the world my
RabbitMQ posts live in. The honest rule: virtual threads
replace reactive-for-concurrency, not reactive-for-backpressure. The full decision table is its own
post.
Jetty flips the same bug the other way
Everything above was measured on Tomcat, which is what the repo runs. The Jetty story I'm about to tell was verified in the Spring Boot and Jetty sources on 2026-07-21, not measured here, and I want to be clear about that line. But it's worth telling, because it's the same lesson wearing the opposite costume.
Jetty has a VirtualThreadPool that, unlike Tomcat's connector, does keep a hard concurrency limit
under virtual threads. Its no-arg constructor is this(200), and on start it builds a
new Semaphore(maxTasks) sized by getMaxConcurrentTasks() and wraps every task in an
acquire/release. Jetty's own code comment is the tidiest one-line explanation of the whole pattern
I've seen: "this is a virtual thread, so acquiring a permit here blocks the virtual thread but does
not pin the carrier." So Jetty virtual threads are bounded at 200 concurrent tasks by default,
always. Good design. The catch is upstream.
Before the regression, the Jetty customizer honored your server.jetty.threads.max and passed it to
the pool. Then it was rewritten to create a new VirtualThreadPool() without passing the property
through, and your knob went silently dead: the effective limit fell back to Jetty's internal default
of 200, and setting the property to 10,000 changed nothing, with no error and no log line. The
affected window spans two release lines: the switch landed in Boot 3.4.11 and was forward-ported
to 3.5.7, so 3.4.11 through 3.5.10 and 4.0.0 through 4.0.2 all silently ignore the knob.
It was fixed the same day, 2026-02-19, in both 3.5.11 and 4.0.3, and carried forward into
the 4.1 line, so the 4.1.0 this repo runs has it.7 The 3.4 line itself never got the fix; it
ended at 3.4.13 in December 2025, so anyone on an affected 3.4.x has to jump to 3.5.11 or later. So
the mirror image is this: on Tomcat, enabling virtual threads quietly removes your 200 gate; on
the affected Jetty builds, it quietly keeps the 200 gate but disconnects your knob from it.
Opposite bugs. Same lesson: you can't config-tune a limit whose location you don't know.
How I measured this, and where it's soft
Now the disclosure, placed here on purpose rather than up front, because you should judge the numbers after you've seen the mechanism they support.
The setup: Spring Boot 4.1.0 on Java 25, Tomcat via spring-boot-starter-webmvc, PostgreSQL in
Docker, HikariCP at maximum-pool-size: 10 and connection-timeout: 30000, held constant across
every wiring. The /work endpoint does one thing, select pg_sleep(0.05), about 50 ms of database
work. The load driver is a deterministic closed-loop spike: 7,000 workers by default (9,500 for the
ceiling run), a 20-second ramp, a 90-second hold, and a fixed start schedule with no randomness, so
runs reproduce. In-flight is measured server-side by a servlet filter feeding a Micrometer gauge;
Tomcat connections come from JMX:
@Component
public class InFlightFilter extends OncePerRequestFilter {
private final AtomicInteger inFlight = new AtomicInteger();
public InFlightFilter(MeterRegistry registry) {
Gauge.builder("http.inflight", inFlight, AtomicInteger::get).register(registry);
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response,
FilterChain chain
) throws ServletException, IOException {
inFlight.incrementAndGet();
try {
chain.doFilter(request, response);
} finally {
inFlight.decrementAndGet();
}
}
}Here's the soft part. Client and server run on the same machine, so treat every number here as
relative and structural, never as an absolute benchmark. What's trustworthy is the shape: where
the queue lives, that in-flight jumps from 200 to 7,000, that connections plateau at exactly 8,192,
that late failures go to zero once a real limit is in place. What I would not quote off this is
throughput-per-second as if it were your production ceiling; on one box, client and server fight for
the same cores. That's also why I keep saying goodput was "roughly unchanged" and not "identical":
25,858 versus 27,319 is within noise for this rig, and I'm not going to pretend a single machine
measured them equal. If you want to poke at any of it, the whole thing runs from mise run demo and
writes its own SUMMARY.txt, so you can disagree with my machine on yours.
The one line to take away
Cheap threads don't remove your concurrency limit. They relocate it, usually somewhere less
convenient than where it started, and usually without telling you. The whole game is knowing where
it went and putting it back somewhere you chose. On Tomcat that's a semaphore or @ConcurrencyLimit
in front of whatever's actually scarce. On Jetty it's checking your Boot version. Everywhere, it's
refusing to trust a default you didn't set.
The companion repo has all four load profiles, the load driver, and the one-button demo that produced every figure here. Run it, break it, and if something's unclear or wrong on your hardware, open an issue. And the question I'd actually like answered in the comments: after you enable virtual threads, where does your deliberate limit live? If you can't point at it in one sentence, that's the post working.
Structured concurrency: the next chapter
Scoped values were only half of the story I gestured at earlier. Here's the other half, and the sequel to this post.
Virtual threads make a single thread cheap. Structured concurrency is the sibling track that makes many of them safe to coordinate.
Once fork() feels free, you fan out: five downstream calls in parallel, one virtual thread each.
But now you own the hard parts. If one call fails, cancel the rest. If the caller walks away, cancel
all of them. And never leak a thread you forgot about. Plain ExecutorService and Future won't
give you that. It's the concurrency equivalent of goto. StructuredTaskScope ties those subtasks
to a lexical scope: they start together, and the scope guarantees they all finish or all get
cancelled, together.
Here's the honest part: it's still a preview feature, through JDK 27 at least, and the API has
been rewritten in most previews. JEP 505 (JDK 25) dropped the public constructor and the
ShutdownOnFailure/ShutdownOnSuccess subclasses for a Joiner; JDK 26 layered JEP 525 on top.
Code you copy from a 2023 tutorial won't compile today. So this is a watch-it, don't-ship-it
section, not a how-to. The one piece that is final and usable now is its partner, scoped values
(JEP 506, JDK 25), the ThreadLocal replacement from the section above.
And notice the lesson carries up a level: structured concurrency gives you structure, not a limit. Fan out across a hundred scopes and you're back to the problem this whole post is about. The deliberate limit still has to live somewhere. Cheap threads didn't delete admission control. They just moved it, again.
I'll dig into StructuredTaskScope properly once it goes final.
Footnotes
-
Every measured number in this post comes from the companion repository, lukas-grigis/spring-virtual-threads, run on 2026-07-21: Spring Boot 4.1.0, Java 25, Tomcat, PostgreSQL in Docker, HikariCP pool of 10 with a 30 s timeout.
mise run demobuilds the app, starts Postgres, runs all four load profiles (platform, vt with a JFR recording, vt-gated, and the 9,500-worker ceiling run) and renders the figures. The per-run counts live inload/results/SUMMARY.txt; the JFR pinned-event count for the vt run was 0. ↩ ↩2 ↩3 -
Spring Boot 4.1.0 configuration appendix,
server.tomcat.threads.max(backed byTomcatServerProperties.Threads#max): "Maximum amount of worker threads. Doesn't have an effect if virtual threads are enabled." Default 200.server.tomcat.max-connectionsdefaults to 8192. ↩ ↩2 -
HikariCP pull request #2055, "Add support for Virtual Threads", closed unmerged. The maintainer's position was that the
synchronizedblocks in question see no real-world contention, and the discussion wound down once JEP 491 was confirmed for JDK 24. ↩ -
Oracle, Core Libraries / Virtual Threads: virtual threads are not a scarce resource and should not be pooled; to limit concurrency, use "a construct designed specifically for that purpose: the
Semaphoreclass." ↩ -
Spring Framework 7, Resilience Features:
@ConcurrencyLimitinorg.springframework.resilience.annotation, documented as particularly useful with virtual threads where there is generally no thread-pool limit in place. Enabled via@EnableResilientMethods(the recommended way; alternatively by registering aConcurrencyLimitBeanPostProcessorbean). ↩ -
spring-projects/spring-boot issue #46916, requesting auto-configuration for
@EnableResilientMethods, closed as not-planned on 2025-08-20: "We decided not to do this as it's a one-liner to enable the feature." ↩ -
Spring Boot's Jetty customizer dropped
server.jetty.threads.maxwhen it switched toVirtualThreadPool: introduced in 3.4.11 (spring-boot #47690) and forward-ported to 3.5.7 (#47717), so 3.4.11–3.5.10 and 4.0.0–4.0.2 all ignore it; fixed the same day, 2026-02-19, in 3.5.11 and 4.0.3 (#48982 / #48989) and forward-ported to 4.1.0-M2 (#48990). The 3.4 line ended at 3.4.13 (December 2025) without the fix. Jetty'sVirtualThreadPoolno-arg constructor isthis(200)and on start builds aSemaphore(maxTasks)sized bygetMaxConcurrentTasks(), so its virtual threads are bounded at 200 by default. Verified against the Spring Boot and Jetty 12.1.10 sources (the Jetty version Boot 4.1.0 bundles), not measured in this repo. ↩
