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.

Java Architecture Diary
Java Architecture Diary
Java Architecture Diary
Why Tomcat Outperforms Jetty 15× with Virtual Threads in Spring Boot 4.0

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       enabled

Enabling virtual threads requires a single property:

spring.threads.virtual.enabled=true

Native 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-vt

The 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-vt

Load‑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 higher

Both 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 s

The 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=true

That’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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

performanceDockerspringbootVirtual Threadsnative-imagetomcatJetty
Java Architecture Diary
Written by

Java Architecture Diary

Committed to sharing original, high‑quality technical articles; no fluff or promotional content.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.