Undertow to Tomcat 11 Migration Guide: Jakarta EE 11, Servlet 6.1, and What Actually Breaks

I had a service still pinned to Undertow when I started the Spring Boot 3 to 4 upgrade — a leftover from a “low memory footprint” benchmark someone ran years ago that nobody ever revisited. The upgrade guides all say the same thing in one sentence: Undertow is gone, move to Tomcat 11 or Jetty 12.1. What they don’t show you is what actually happens when you try to keep it anyway, or what you get for free once you stop. So I tried to keep it anyway, on a real Spring Boot 4.1.0 project, and captured exactly what breaks.

This guide walks through that, beginner to advanced: what a servlet container even is if you’ve never had to think about it, why Undertow specifically fell out of the Jakarta EE 11 baseline, the real Maven error and the real runtime crash you get if you fight it, and the two servers — Tomcat 11 and Jetty 12.1 — that actually work on Spring Boot 4 today. Every command and every error message below is copy-pasted from a real run, not paraphrased from release notes.

Where this guide is going

What a servlet container actually does

If you’ve only ever run mvn spring-boot:run and watched a port come up, it’s easy to never think about this: a Spring MVC application doesn’t talk HTTP on its own. It hands that job to a servlet container — a small HTTP server that accepts the raw TCP connection, parses the request line and headers, and hands off a HttpServletRequest/HttpServletResponse pair to Spring’s DispatcherServlet, which is where your @RestController code actually runs. Spring Boot embeds one of these servers inside your JAR so java -jar app.jar is a complete, self-contained web server — no separate Tomcat install, no WAR file to deploy.

Three servlet containers have historically been swappable as Spring Boot starters: Tomcat (the default, from the Apache project best known for standalone servlet hosting), Jetty (Eclipse’s lighter-weight alternative, popular for embedded and reactive use), and Undertow (Red Hat’s non-blocking server, chosen historically for lower memory overhead under high concurrency). All three implement the same Jakarta Servlet specification, which is exactly why swapping between them used to be a one-dependency change. That contract is also exactly what broke for Undertow.

ServerBoot 3Boot 4 (Servlet 6.1 / Jakarta EE 11)
TomcatSupported (10.1)Supported (11.0) — default
JettySupported (11)Supported (12.1)
UndertowSupportedNot supported — no Servlet 6.1 release exists

What Spring Boot 4 gives you by default

Add plain spring-boot-starter-web to a fresh Spring Boot 4.1.0 project and you already have a servlet container — no server-specific dependency needed. Running mvn dependency:tree against a minimal project shows exactly what gets pulled in:

com.ankurm:undertow-tomcat-demo:jar:0.0.1-SNAPSHOT
  - org.springframework.boot:spring-boot-starter-tomcat:jar:4.1.0:compile
     - org.springframework.boot:spring-boot-starter-tomcat-runtime:jar:4.1.0:compile
        +- org.apache.tomcat.embed:tomcat-embed-core:jar:11.0.22:compile
        +- org.apache.tomcat.embed:tomcat-embed-el:jar:11.0.22:compile
        - org.apache.tomcat.embed:tomcat-embed-websocket:jar:11.0.22:compile

Two things worth noting here that are new in Boot 4’s dependency layout: there’s now an explicit spring-boot-starter-tomcat artifact (previously Tomcat was just a transitive dependency management entry, not its own starter), and it further delegates to spring-boot-starter-tomcat-runtime — part of the general modularization Boot 4 did across the board, breaking previously-monolithic starters into smaller, more composable pieces.

Starting the app confirms the exact version in the boot log, no dependency-tree spelunking required:

o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/11.0.22]
o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8085 (http) with context path '/' 

Tomcat 11.0.22, confirmed. If you’ve done nothing special to your dependencies since upgrading to Boot 4, this is already what you’re running — the migration most teams need is simply confirming this, not doing anything. The interesting case is the team that explicitly pinned Undertow, which is where this gets less pleasant.

Why Undertow specifically is gone

Spring Framework 7.0 raised its servlet baseline to Jakarta Servlet 6.1, part of the broader Jakarta EE 11 baseline (alongside JPA 3.2 and Bean Validation 3.1). Tomcat 11 and Jetty 12.1 both shipped Servlet 6.1 support in time for Framework 7’s release. Undertow, as of this writing, has not — and Spring can’t build an embedded-server integration against a servlet API version the underlying server doesn’t implement. So Spring dropped its Undertow-specific WebSocket and low-level WebFlux HTTP support, and the Boot team stopped publishing the Undertow starter.

This isn’t a policy decision Spring can reverse by choice — it’s a hard dependency on Undertow’s own release cadence. The moment a Servlet-6.1-compatible Undertow ships, the door reopens. Until then, Boot 4 simply has no supported embedded Undertow.

What actually happens if you try to keep Undertow

Rather than take that on faith, I pointed a real Boot 4.1.0 project at spring-boot-starter-undertow with no version pinned — the normal way you’d add any Boot starter, relying on the parent BOM to supply the version:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-undertow</artifactId>
    <!-- no version -- letting the 4.1.0 parent BOM supply one, as usual -->
</dependency>

Maven refused to even build the project:

[ERROR] Some problems were encountered while processing the POMs:
[ERROR] 'dependencies.dependency.version' for org.springframework.boot:spring-boot-starter-undertow:jar is missing.
[ERROR] The build could not read 1 project -> [Help 1]

That error is the whole story in one line: the Boot 4.1.0 BOM has no entry for spring-boot-starter-undertow at all. I confirmed this directly against Maven Central’s metadata for the artifact — the last version ever published is 4.0.0-M1, a pre-release milestone from before Boot 4 even reached GA. It was never republished for 4.0.0, any 4.0.x patch, or 4.1.0. If you want Undertow on Boot 4, you have to hardcode a version yourself, and the only one that exists predates the GA release you’re actually running.

Forcing an old version anyway

Out of curiosity — and because I know someone reading this is going to try it — I hardcoded that last-ever 4.0.0-M1 version explicitly:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-undertow</artifactId>
    <version>4.0.0-M1</version>
</dependency>

This time Maven was happy — mvn compile succeeded, because nothing about the M1 jar’s API surface changed at the source level. The problem shows up at runtime, the moment Spring Boot tries to actually configure the embedded server. Here is the real, unedited stack trace from mvn spring-boot:run:

An attempt was made to call a method that does not exist. The attempt was made from the following location:

    org.springframework.boot.undertow.autoconfigure.UndertowWebServerFactoryCustomizer.customize(UndertowWebServerFactoryCustomizer.java:81)

The following method did not exist:

    'org.springframework.boot.context.properties.PropertyMapper org.springframework.boot.context.properties.PropertyMapper.alwaysApplyingWhenNonNull()'

The calling method's class, org.springframework.boot.undertow.autoconfigure.UndertowWebServerFactoryCustomizer, was loaded from the following location:

    jar:file:/.../spring-boot-undertow/4.0.0-M1/spring-boot-undertow-4.0.0-M1.jar!/org/springframework/boot/undertow/autoconfigure/UndertowWebServerFactoryCustomizer.class

The called method's class, org.springframework.boot.context.properties.PropertyMapper, is available from the following locations:

    jar:file:/.../spring-boot/4.1.0/spring-boot-4.1.0.jar!/org/springframework/boot/context/properties/PropertyMapper.class

Action:

Correct the classpath of your application so that it contains compatible versions of the classes org.springframework.boot.undertow.autoconfigure.UndertowWebServerFactoryCustomizer and org.springframework.boot.context.properties.PropertyMapper

[ERROR] Failed to execute goal org.springframework.boot:spring-boot-maven-plugin:4.1.0:run (default-cli) on project: Process terminated with exit code: 1

A NoSuchMethodError, not a graceful startup failure. The old spring-boot-undertow:4.0.0-M1 autoconfiguration class calls a PropertyMapper method that simply doesn’t exist in the current spring-boot-4.1.0.jar — the internal API moved between the M1 milestone and GA, as internal APIs are allowed to. This is not a supported combination in any sense; it’s two incompatible builds of the same framework colliding at the bytecode level. If you see this specific error in your own logs, the fix isn’t a different Undertow version — there isn’t one — it’s removing Undertow.

Decision flowchart: if you are using spring-boot-starter-undertow, remove it and choose Tomcat 11 or Jetty 12.1; otherwise you are already on Tomcat 11 and there is nothing to change

Tuning Tomcat 11 once you are on it

With the Undertow question settled, the practical path for most teams is simply staying on the Boot 4 default — Tomcat 11 — and tuning it rather than replacing it. A few properties worth knowing about, since Undertow refugees often came over specifically for concurrency tuning:

# Max simultaneous request-handling threads (default: 200)
server.tomcat.threads.max=400

# Minimum idle threads kept warm (default: 10)
server.tomcat.threads.min-spare=20

# Max queued connections waiting for a thread (default: 100)
server.tomcat.accept-count=200

# Run request handling on virtual threads instead of the platform thread pool
spring.threads.virtual.enabled=true

That last property is the modern answer to the concurrency concern that used to push people toward Undertow in the first place. With spring.threads.virtual.enabled=true on Java 21+, Tomcat hands each request a lightweight virtual thread instead of a pooled platform thread, which is a much bigger concurrency win under blocking I/O than picking a different servlet container ever was. If raw memory footprint under concurrency was your original reason for Undertow, benchmark this against your workload before assuming you still need a different server at all.

The working alternative: Jetty 12.1

If Tomcat genuinely isn’t the right fit — some teams prefer Jetty’s programmatic configuration model, or already standardize on it elsewhere — it’s a real, supported swap on Boot 4, unlike Undertow. Exclude the Tomcat starter and add Jetty’s:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jetty</artifactId>
</dependency>

I ran this against the same app used throughout this guide. It started cleanly, no version pinning needed:

org.eclipse.jetty.server.Server          : jetty-12.1.10; built: 2026-05-31T04:45:52.925Z; jvm 25.0.3+9-LTS-195
o.s.boot.jetty.JettyWebServer            : Jetty started on port 8086 (http/1.1) with context path '/'

> curl http://localhost:8086/ping
pong

Jetty 12.1.10, resolved automatically from the Boot 4.1.0 BOM — exactly the Servlet 6.1-compatible Jetty generation Framework 7 requires, no manual version wrangling like the Undertow attempt needed. If your team is migrating away from Undertow specifically for a non-blocking, lightweight server rather than raw familiarity with Tomcat, Jetty is the closer conceptual match and the one that actually works today.

The migration checklist

  • Grep your pom.xml/build.gradle for spring-boot-starter-undertow before you even start the Boot 4 upgrade — this is a hard blocker, not a deprecation warning.
  • If you find it, confirm you’re not accidentally pinning a version yourself; without a pin, the build fails immediately with a missing-BOM-entry error (a fast, honest failure — better than the alternative).
  • Do not hardcode 4.0.0-M1 or any other Undertow version to work around the missing BOM entry. It compiles, then crashes at startup with a NoSuchMethodError from internal API drift between that milestone and the GA core.
  • Default to removing the Undertow dependency entirely and letting spring-boot-starter-web pull in Tomcat 11 automatically — this is almost certainly what you want unless you have a specific, re-validated reason not to.
  • If your original reason for Undertow was concurrency/memory under load, benchmark spring.threads.virtual.enabled=true on Tomcat 11 against your real workload before assuming you still need a different server.
  • If you genuinely need a different server, swap to spring-boot-starter-jetty (excluding spring-boot-starter-tomcat) rather than fighting Undertow — it resolves cleanly from the Boot 4 BOM with no version pinning.
  • Re-run any WebSocket or low-level WebFlux code you had that was Undertow-specific — Spring dropped those integration classes specifically, so this is the code most likely to reference removed APIs directly.
  • Budget a short regression pass regardless of which server you land on: the Servlet 6.1 generation (Tomcat 11, Jetty 12.1) tightens some request-handling behavior — stricter cookie and invalid-request handling among them — that older containers may have silently tolerated.

An AI prompt to find Undertow blockers in your project

Paste this into Claude, ChatGPT, Gemini, or Cursor with your repository in context.

You are auditing a Java/Spring codebase for a Spring Boot 3 -> 4 migration,
specifically for Undertow removal (Undertow has no supported embedded-server
integration on Spring Boot 4 -- the last spring-boot-starter-undertow version
ever published is 4.0.0-M1, a pre-GA milestone).

Scan the project and report, in this order:
1. DEPENDENCY BLOCKERS: any spring-boot-starter-undertow entry in pom.xml or
   build.gradle[.kts], with or without an explicit version. Flag explicit
   version pins to old milestones as especially dangerous -- they compile but
   crash at runtime with a NoSuchMethodError from internal API drift.
2. UNDERTOW-SPECIFIC CODE: imports from io.undertow.* or Spring's
   Undertow-specific WebSocket/WebFlux integration classes -- these have no
   direct equivalent and need to be rewritten against the standard Servlet
   API or Tomcat/Jetty-specific config instead.
3. CONCURRENCY CONFIG: any server.undertow.* properties in
   application.properties/yml that need translating to server.tomcat.* or
   server.jetty.* equivalents (thread pools, buffer sizes, I/O settings).
4. RECOMMENDATION: given the code found, recommend Tomcat 11 (default,
   simplest migration) or Jetty 12.1 (closer match if the original Undertow
   choice was for non-blocking I/O), with the exact dependency change needed.

For each finding give file:line and the exact replacement. End with a
migration plan ordered by risk.

Frequently asked questions

Is Undertow permanently removed from Spring Boot?

Not by policy — by dependency. Spring Framework 7 requires Jakarta Servlet 6.1, and Undertow hasn’t shipped a Servlet 6.1-compatible release. The moment it does, an embedded Undertow integration becomes possible again. As of Boot 4.1.0, it doesn’t exist.

What’s the actual last version of spring-boot-starter-undertow?

4.0.0-M1, confirmed directly from Maven Central’s metadata for the artifact — a pre-GA milestone, never republished for 4.0.0 GA or any release after it.

What happens if I force that old version onto Boot 4.1.0 anyway?

It compiles fine, then fails at startup with a real NoSuchMethodError — captured in the section above — because the old Undertow autoconfiguration jar calls an internal Spring Boot API method that no longer exists in the current core. This is not a supported or safe combination.

Is Tomcat 11 a big change from Tomcat 10.1?

For most application code, no — it’s Boot’s own default, so if you haven’t touched your server dependencies since upgrading to Boot 4, you’re already running it. The generational jump (Servlet 6.1) does tighten some request-handling behavior versus the Tomcat 10.1/Servlet 6.0 generation, so a short regression pass is worth it.

Should I switch to Jetty instead of Tomcat?

Only if you have a specific reason to — a team already standardized on Jetty, or a preference for its configuration model. It’s a real, working option on Boot 4 (verified in the section above), but Tomcat 11 is the default for a reason: it needs zero extra dependency changes.

See also

Conclusion

The honest summary of this migration is shorter than the guide: if you haven’t touched your servlet container dependency, you’re already done — Tomcat 11 is Boot 4’s default and it just works. The only teams with real work here are the ones who explicitly chose Undertow, and for them the path is equally short: remove it, let Tomcat 11 take over, and reach for spring.threads.virtual.enabled=true if concurrency was the original motivation. What I’d steer you away from, having now seen it fail firsthand, is trying to keep Undertow alive by pinning an old version — it doesn’t fail at build time where you’d catch it safely, it fails at startup with a NoSuchMethodError that looks like a mysterious framework bug until you know exactly what caused it.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.