Why Tomcat Outperforms Jetty 15× with Virtual Threads in Spring Boot 4.0
A detailed benchmark shows that enabling Java virtual threads in Spring Boot 4.0 makes Tomcat handle up to fifteen times more requests than Jetty, while startup times remain similar, and the article explains the test setup, configuration steps, native image builds, and the reasons behind Jetty's limitation.
Conclusion
If you run Spring Boot 4.0 with virtual threads, choose Tomcat; Jetty’s performance with virtual threads is almost the same as without them.
Test Environment
SpringBoot: 4.0.1
JDK: 25
Test machine: MacBook Pro M4, 32 GB RAM
Docker image memory limit: 1024 MB
Load‑testing tool: wrk
Test duration: 30 seconds
The test endpoint is a simple /pay that sleeps for one second to simulate an I/O block:
@Service
public class PayService {
private static final String[] CHANNELS = {"ALIPAY","WECHAT","UNION","VISA","MASTER"};
public String doPay(String orderId) {
try {
String channel = CHANNELS[ThreadLocalRandom.current().nextInt(CHANNELS.length)];
Thread.sleep(1000); // simulate payment gateway call
return "Order %s paid via %s".formatted(orderId, channel);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}Controller implementation:
@RestController
@RequestMapping("/pay")
public class PayController {
private static final Logger log = LoggerFactory.getLogger(PayController.class);
private static final AtomicInteger counter = new AtomicInteger(1);
private final PayService payService;
public PayController(PayService payService) { this.payService = payService; }
@GetMapping
public String pay(@RequestParam(required = false, defaultValue = "PIG2026") String orderId) {
int id = counter.getAndIncrement();
log.info("Request {} processed by {}", id, Thread.currentThread());
String result = payService.doPay(orderId);
log.info("Request {} resumed by {}", id, Thread.currentThread());
return result;
}
}Four Docker Images for Comparison
Image Name Container Virtual‑Thread
------------------------------------------------
tomcat Tomcat disabled
tomcat-vt Tomcat enabled
jetty Jetty disabled
jetty-vt Jetty enabledEnabling virtual threads requires a single property:
spring.threads.virtual.enabled=trueNative Image Build
The tests use GraalVM Native Image. Spring Boot 4.0 now supports native images smoothly.
Build commands (replace … with the appropriate image name):
# Tomcat without virtual threads
./mvnw clean -DskipTests spring-boot:build-image -Pnative \
-Dspring-boot.build-image.imageName=pig-pay-tomcat:4.0.0-25-native
# Tomcat with virtual threads
echo "spring.threads.virtual.enabled=true" >> src/main/resources/application.properties
./mvnw clean -DskipTests spring-boot:build-image -Pnative \
-Dspring-boot.build-image.imageName=pig-pay-tomcat:4.0.0-25-native-vt
# Jetty without virtual threads
./mvnw clean -DskipTests spring-boot:build-image -Pjetty,native \
-Dspring-boot.build-image.imageName=pig-pay-jetty:4.0.0-25-native
# Jetty with virtual threads
echo "spring.threads.virtual.enabled=true" >> src/main/resources/application.properties
./mvnw clean -DskipTests spring-boot:build-image -Pjetty,native \
-Dspring-boot.build-image.imageName=pig-pay-jetty:4.0.0-25-native-vtThe four resulting native images are:
- pig-pay-tomcat:4.0.0-25-native
- pig-pay-tomcat:4.0.0-25-native-vt
- pig-pay-jetty:4.0.0-25-native
- pig-pay-jetty:4.0.0-25-native-vtLoad‑Testing Results
Baseline (no virtual threads)
Concurrency Tomcat QPS Jetty QPS Difference
------------------------------------------------
100 95.96 94.19 ~equal
500 192.76 187.08 Tomcat slightly higher
1000 192.92 187.06 Tomcat slightly higher
3000 179.49 171.11 Tomcat slightly higher
5000 114.23 98.66 Tomcat higherBoth containers hit the 200‑thread ceiling, as expected.
With Virtual Threads Enabled
Concurrency Tomcat‑VT QPS Jetty‑VT QPS Difference
------------------------------------------------
100 96.45 95.53 ~equal
500 477.99 191.90 Tomcat ~2.5×
1000 947.68 191.96 Tomcat ~5×
3000 2699.67 178.13 Tomcat ~15×
5000 616.43 112.09 Tomcat ~5.5×Tomcat’s QPS jumps dramatically when virtual threads are enabled, while Jetty’s QPS remains almost unchanged.
Why Jetty Doesn’t Benefit
Jetty still respects the server.jetty.threads.max=200 limit even with virtual threads, preventing the creation of many cheap threads. Tomcat, on the other hand, ignores server.tomcat.threads.max=200 when virtual threads are enabled, allowing the JVM to schedule thousands of virtual threads freely.
Startup Time Comparison
Image Startup Time
----------------------------
tomcat 0.643 s
tomcat‑vt 0.658 s
jetty 0.806 s
jetty‑vt 0.710 sThe differences are under one second, so startup speed is comparable.
Switching Containers
If you are using Jetty and want to switch to Tomcat, replace the Jetty starter with the default spring-boot-starter-web dependency and enable virtual threads:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency> spring.threads.virtual.enabled=trueThat’s all that’s needed.
Takeaway
While Undertow was removed from Spring Boot 4.0, Tomcat remains a reliable default. Its ability to fully leverage virtual threads makes it dramatically faster under high concurrency, whereas Jetty’s legacy thread‑pool limits prevent it from gaining the same benefit.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Architecture Diary
Committed to sharing original, high‑quality technical articles; no fluff or promotional content.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
