Why Tomcat Beats Jetty 15× with SpringBoot 4 Virtual Threads
A performance comparison of SpringBoot 4.0 with virtual threads shows Tomcat achieving up to fifteen times higher QPS than Jetty, while startup times remain similar, and provides step‑by‑step instructions for building native Docker images and switching containers.
Conclusion
If you run SpringBoot 4.0 with virtual threads, choose Tomcat; Jetty shows almost no improvement.
Test Environment
SpringBoot 4.0.1
JDK 25
Machine: MacBook Pro M4, 32 GB RAM
Docker memory limit: 1024 MB
Load tool: wrk, 30 seconds per run
Test endpoint: /pay which sleeps 1 second to simulate I/O.
@Service
public class PayService {
private static final String[] CHANNELS = new String[]{"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);
}
}
} @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;
}
}Docker Images
tomcat (no virtual threads)
tomcat‑vt (virtual threads enabled)
jetty (no virtual threads)
jetty‑vt (virtual threads enabled)
Enable virtual threads with a single property:
spring.threads.virtual.enabled=trueNative Image Build
Using GraalVM Native Image, the build commands are:
# 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 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-vt
Build time on an M4 Mac is roughly 3–5 minutes per image.
Benchmark Results
Baseline (no virtual threads)
Concurrency 100: Tomcat QPS 95.96, Jetty QPS 94.19 (≈ equal)
Concurrency 500: Tomcat 192.76, Jetty 187.08 (Tomcat slightly ahead)
Concurrency 1000: Tomcat 192.92, Jetty 187.06 (Tomcat slightly ahead)
Concurrency 3000: Tomcat 179.49, Jetty 171.11 (Tomcat slightly ahead)
Concurrency 5000: Tomcat 114.23, Jetty 98.66 (Tomcat ahead)
Both containers hit the 200‑thread ceiling.
With Virtual Threads
Concurrency 100: Tomcat‑VT 96.45, Jetty‑VT 95.53 (≈ equal)
Concurrency 500: Tomcat‑VT 477.99, Jetty‑VT 191.90 (Tomcat 2.5× faster)
Concurrency 1000: Tomcat‑VT 947.68, Jetty‑VT 191.96 (Tomcat 5× faster)
Concurrency 3000: Tomcat‑VT 2699.67, Jetty‑VT 178.13 (Tomcat 15× faster)
Concurrency 5000: Tomcat‑VT 616.43, Jetty‑VT 112.09 (Tomcat 5.5× faster)
Tomcat’s QPS jumps from ~179 to ~2699 (≈15×) when virtual threads are enabled, while Jetty’s QPS stays around 170‑180.
Why Jetty Lags
Jetty still respects server.jetty.threads.max=200 even with virtual threads, limiting the number of threads it can schedule. Tomcat, however, ignores server.tomcat.threads.max=200 when virtual threads are enabled, allowing many more concurrent virtual threads.
Startup Time Comparison
tomcat: 0.643 s
tomcat‑vt: 0.658 s
jetty: 0.806 s
jetty‑vt: 0.710 s
All native images start within one second, showing comparable startup performance.
Switching from Jetty to Tomcat
Replace the Jetty starter with the default spring-boot-starter-web dependency and enable virtual threads via the property above. No additional configuration is required.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency> spring.threads.virtual.enabled=trueTakeaway
While SpringBoot 4.0 dropped Undertow, Tomcat remains a robust choice. With virtual threads, Tomcat can achieve dramatic throughput gains, whereas Jetty’s legacy thread limits prevent it from benefiting similarly.
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.
macrozheng
Dedicated to Java tech sharing and dissecting top open-source projects. Topics include Spring Boot, Spring Cloud, Docker, Kubernetes and more. Author’s GitHub project “mall” has 50K+ stars.
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.
